玩弄心里的鬼

玩弄心里的鬼 查看完整档案

北京编辑天津理工大学  |  计算机 编辑饿了么  |  前端工程师 编辑 github.com/LNoe-lzy 编辑
编辑

个人动态

玩弄心里的鬼 发布了文章 · 11月19日

解密微前端:从qiankun看沙箱隔离

在我之前的文章提到过,微前端的本质是分治的处理前端应用以及应用间的关系,那么更进一步,落地一个微前端框架,就会涉及到三点核心要素:

  • 子应用的加载;
  • 应用间运行时隔离
  • 路由劫持;

对于 qiankun 来说,路由劫持是在 single-spa 上去做的,而 qiankun 给我们提供的能力,主要便是子应用的加载和沙箱隔离。

承接上文,这是系列的第二个 topic,这篇文章主要基于 qiankun 源码向大家讲一下沙箱隔离如何实现。

qiankun 做沙箱隔离主要分为三种:

  • legacySandBox
  • proxySandBox
  • snapshotSandBox。

其中 legacySandBox、proxySandBox 是基于 Proxy API 来实现的,在不支持 Proxy API 的低版本浏览器中,会降级为 snapshotSandBox。在现版本中,legacySandBox 仅用于 singular 单实例模式,而多实例模式会使用 proxySandBox。

legacySandBox

legacySandBox 的核心思想是什么呢?legacySandBox 的本质上还是操作 window 对象,但是他会存在三个状态池,分别用于子应用卸载时还原主应用的状态和子应用加载时还原子应用的状态

  • addedPropsMapInSandbox: 存储在子应用运行时期间新增的全局变量,用于卸载子应用时还原主应用全局变量;
  • modifiedPropsOriginalValueMapInSandbox:存储在子应用运行期间更新的全局变量,用于卸载子应用时还原主应用全局变量;
  • currentUpdatedPropsValueMap:存储子应用全局变量的更新,用于运行时切换后还原子应用的状态;

我们首先看下 Proxy 的 getter / setter:

const rawWindow = window;
const fakeWindow = Object.create(null) as Window;
// 创建对fakeWindow的劫持,fakeWindow就是我们传递给自执行函数的window对象
const proxy = new Proxy(fakeWindow, {
  set(_: Window, p: PropertyKey, value: any): boolean {
    // 运行时的判断
    if (sandboxRunning) {
      // 如果window对象上没有这个属性,那么就在状态池中记录状态的新增;
      if (!rawWindow.hasOwnProperty(p)) {
        addedPropsMapInSandbox.set(p, value);

        // 如果当前 window 对象存在该属性,并且状态池中没有该对象,那么证明改属性是运行时期间更新的值,记录在状态池中用于最后window对象的还原
      } else if (!modifiedPropsOriginalValueMapInSandbox.has(p)) {
        const originalValue = (rawWindow as any)[p];
        modifiedPropsOriginalValueMapInSandbox.set(p, originalValue);
      }

      // 记录全局对象修改值,用于后面子应用激活时还原子应用
      currentUpdatedPropsValueMap.set(p, value);
      (rawWindow as any)[p] = value;

      return true;
    }

    return true;
  },

  get(_: Window, p: PropertyKey): any {
    // iframe的window上下文
    if (p === "top" || p === "window" || p === "self") {
      return proxy;
    }

    const value = (rawWindow as any)[p];
    return getTargetValue(rawWindow, value);
  },
});

接下来看下子应用沙箱的激活 / 卸载:

  // 子应用沙箱激活
  active() {
    // 通过状态池,还原子应用上一次写在前的状态
    if (!this.sandboxRunning) {
      this.currentUpdatedPropsValueMap.forEach((v, p) => setWindowProp(p, v));
    }

    this.sandboxRunning = true;
  }

  // 子应用沙箱卸载
  inactive() {
    // 还原运行时期间修改的全局变量
    this.modifiedPropsOriginalValueMapInSandbox.forEach((v, p) => setWindowProp(p, v));
    // 删除运行时期间新增的全局变量
    this.addedPropsMapInSandbox.forEach((_, p) => setWindowProp(p, undefined, true));

    this.sandboxRunning = false;
  }

所以,总结起来,legacySandBox 还是会操作 window 对象,但是他通过激活沙箱时还原子应用的状态,卸载时还原主应用的状态来实现沙箱隔离的

proxySandBox

在 qiankun 中,proxySandBox 用于多实例场景。什么是多实例场景,这里我简单提下,一般我们的中后台系统同一时间只会加载一个子应用的运行时。但是也存在这样的场景,某一个子应用聚合了多个业务域,这样的子应用往往会经历多个团队的多个同学共同维护自己的业务模块,这时候便可以采用多实例的模式聚合子模块(这种模式也可以叫微前端模块)。

回到正题,和 legacySandBox 最直接的不同点就是,为了支持多实例的场景,proxySandBox 不会直接操作 window 对象。并且为了避免子应用操作或者修改主应用上诸如 window、document、location 这些重要的属性,会遍历这些属性到子应用 window 副本(fakeWindow)上,我们首先看下创建子应用 window 的副本:

function createFakeWindow(global: Window) {
  // 这里qiankun给我们了一个知识点:在has和check的场景下,map有着更好的性能 :)
  const propertiesWithGetter = new Map<PropertyKey, boolean>();
  const fakeWindow = {} as FakeWindow;

  // 从window对象拷贝不可配置的属性
  // 举个例子:window、document、location这些都是挂在Window上的属性,他们都是不可配置的
  // 拷贝出来到fakeWindow上,就间接避免了子应用直接操作全局对象上的这些属性方法
  Object.getOwnPropertyNames(global)
    .filter((p) => {
      const descriptor = Object.getOwnPropertyDescriptor(global, p);
      // 如果属性不存在或者属性描述符的configurable的话
      return !descriptor?.configurable;
    })
    .forEach((p) => {
      const descriptor = Object.getOwnPropertyDescriptor(global, p);
      if (descriptor) {
        // 判断当前的属性是否有getter
        const hasGetter = Object.prototype.hasOwnProperty.call(
          descriptor,
          "get"
        );

        // 为有getter的属性设置查询索引
        if (hasGetter) propertiesWithGetter.set(p, true);

        // freeze the descriptor to avoid being modified by zone.js
        // zone.js will overwrite Object.defineProperty
        // const rawObjectDefineProperty = Object.defineProperty;
        // 拷贝属性到fakeWindow对象上
        rawObjectDefineProperty(fakeWindow, p, Object.freeze(descriptor));
      }
    });

  return {
    fakeWindow,
    propertiesWithGetter,
  };
}

接下来看下 proxySandBox 的 getter/setter:

const rawWindow = window;
// window副本和上面说的有getter的属性的索引
const { fakeWindow, propertiesWithGetter } = createFakeWindow(rawWindow);

const descriptorTargetMap = new Map<PropertyKey, SymbolTarget>();
const hasOwnProperty = (key: PropertyKey) =>
  fakeWindow.hasOwnProperty(key) || rawWindow.hasOwnProperty(key);

const proxy = new Proxy(fakeWindow, {
  set(target: FakeWindow, p: PropertyKey, value: any): boolean {
    if (sandboxRunning) {
      // 在fakeWindow上设置属性值
      target[p] = value;
      // 记录属性值的变更
      updatedValueSet.add(p);

      // SystemJS属性拦截器
      interceptSystemJsProps(p, value);

      return true;
    }

    // 在 strict-mode 下,Proxy 的 handler.set 返回 false 会抛出 TypeError,在沙箱卸载的情况下应该忽略错误
    return true;
  },

  get(target: FakeWindow, p: PropertyKey): any {
    if (p === Symbol.unscopables) return unscopables;

    // 避免window.window 或 window.self 或window.top 穿透sandbox
    if (p === "top" || p === "window" || p === "self") {
      return proxy;
    }

    if (p === "hasOwnProperty") {
      return hasOwnProperty;
    }

    // 批处理场景下会有场景使用,这里就不多赘述了
    const proxyPropertyGetter = getProxyPropertyGetter(proxy, p);
    if (proxyPropertyGetter) {
      return getProxyPropertyValue(proxyPropertyGetter);
    }

    // 取值
    const value = propertiesWithGetter.has(p)
      ? (rawWindow as any)[p]
      : (target as any)[p] || (rawWindow as any)[p];
    return getTargetValue(rawWindow, value);
  },

  // 还有一些对属性做操作的代码我就不一一列举了,可以自行查阅源码
});

接下来看下 proxySandBox 的 激活 / 卸载:

  active() {
    this.sandboxRunning = true;
    // 当前激活的子应用沙箱实例数量
    activeSandboxCount++;
  }

  inactive() {
    clearSystemJsProps(this.proxy, --activeSandboxCount === 0);

    this.sandboxRunning = false;
  }

可见,因为 proxySandBox 不直接操作 window,所以在激活和卸载的时候也不需要操作状态池更新 / 还原主子应用的状态了。相比较看来,proxySandBox 是现阶段 qiankun 中最完备的沙箱模式,完全隔离了主子应用的状态,不会像 legacySandBox 模式下在运行时期间仍然会污染 window。

snapshotSandBox

最后一种沙箱就是 snapshotSandBox,在不支持 Proxy 的场景下会降级为 snapshotSandBox,如同他的名字一样,snapshotSandBox 的原理就是在子应用激活 / 卸载时分别去通过快照的形式记录/还原状态来实现沙箱的。

源码很简单,直接看源码:

  active() {
    if (this.sandboxRunning) {
      return;
    }


    this.windowSnapshot = {} as Window;
    // iter方法就是遍历目标对象的属性然后分别执行回调函数
    // 记录当前快照
    iter(window, prop => {
      this.windowSnapshot[prop] = window[prop];
    });

    // 恢复之前运行时状态的变更
    Object.keys(this.modifyPropsMap).forEach((p: any) => {
      window[p] = this.modifyPropsMap[p];
    });

    this.sandboxRunning = true;
  }

  inactive() {
    this.modifyPropsMap = {};

    iter(window, prop => {
      if (window[prop] !== this.windowSnapshot[prop]) {
        // 记录变更,恢复环境
        this.modifyPropsMap[prop] = window[prop];
        window[prop] = this.windowSnapshot[prop];
      }
    });

    this.sandboxRunning = false;
  }

总结起来,对当前的 window 和记录的快照做 diff 来实现沙箱。

css 隔离

这其实是个沉重的话题,从我做微前端到现在对于 css 的处理也没有太好的办法,这里我直接总结了两种目前项目中使用的方案大家可以参考。

约定式编程

这里我们可以采用一定的编程约束:

  • 尽量不要使用可能冲突全局的 class 或者直接为标签定义样式;
  • 定义唯一的 class 前缀,现在的项目都是用诸如 antd 这样的组件库,这类组件库都支持自定义组件 class 前缀;
  • 主应用一定要有自定义的 class 前缀;

css in js

这种方式其实有待商榷,因为完全的 css in js 虽然一定会实现 css 隔离,但是其实这样的编程写法不利于我们后期的项目维护并且也比较难去抽离一些公共 css。

推荐阅读

查看原文

赞 6 收藏 4 评论 0

玩弄心里的鬼 发布了文章 · 11月6日

解密微前端:从qiankun看子应用加载

在我之前的文章提到过,微前端的本质是分治的处理前端应用以及应用间的关系,那么更进一步,落地一个微前端框架,就会涉及到三点核心要素:

  • 子应用的加载;
  • 应用见运行时隔离;
  • 路由劫持;

对于 qiankun 来说,路由劫持是在 single-spa 上去做的,而 qiankun 给我们提供的能力,主要便是子应用的加载和沙箱隔离。

我将分为三个 topic 去讲,这边文章主要基于 qiankun 源码像大家讲一下微前端子应用的加载。

qiankun 是 single-spa 的一层封装,而 qiankun 中,真正去加载解析子应用的逻辑是在 import-html-entry 这个包中实现的。

html 解析

首先,当我们配置子应用的 entry 后,qiankun 会去通过 fetch 获取到子应用的 html 字符串(这就是为什么需要子应用资源允许跨域)
拿到 html 字符串后,会调用 processTpl 方法通过一大堆正则去匹配获取 html 中对应的 js(内联、外联)、css(内联、外联)、注释、入口脚本 entry 等等。processTpl 方法会返回我们加载子应用所需要的四个组成部分:

  • template:html 模板;
  • script:js 脚本(内联、外联);
  • styles:css 样式表(内联、外联);
  • entry:子应用入口 js 脚本文件,如果没有默认以解析后的最后一个 js 脚本代替;
export default function processTpl(tpl, baseURI) {
  // 省略详细代码,这里是对各种css、js等资源各种写法的预处理,用于规范后面对资源的统一处理

  return {
    template, // html 模板
    scripts, // js 脚本(内联、外联)
    styles, // css 样式表(内联、外联)
    entry: entry || scripts[scripts.length - 1], // 子应用入口 js 脚本文件,如果没有默认以解析后的最后一个 js 脚本代替;
  };
}

css 处理

接下来在拿到子应用的依赖的各种资源关系后,会去通过 fetch 获取 css,并将 css 全部以内联形式嵌入 html 模板中,源码位置。到此对 css 的处理大致就完成了。

function getEmbedHTML(template, styles, opts = {}) {
  const { fetch = defaultFetch } = opts;
  let embedHTML = template;

  // 获取css资源
  // getExternalStyleSheets 同时处理了内联和外联css资源
  // 其中内联资源会获取css code,外联会先fetch 到css code然后处理
  return getExternalStyleSheets(styles, fetch).then((styleSheets) => {
    embedHTML = styles.reduce((html, styleSrc, i) => {
      // 内联处理全部的css资源
      html = html.replace(
        genLinkReplaceSymbol(styleSrc),
        `<style>/* ${styleSrc} */${styleSheets[i]}</style>`
      );
      return html;
    }, embedHTML);
    return embedHTML;
  });
}

这里我以真实项目做了对比:

未嵌入父应用的子应用:

嵌入父应用的子应用:

js 处理

接下来是对 js 的处理,这里 qiankun 和 icestack 的处理模式就不同了。

首先简单说下 icestark,icestark 是在解析完 html 后拿到子应用的 js 依赖,通过动态创建 script 标签的形式去加载 js,因此在 icestark 是无视 js 跨域的(icestark 的 entry 模式和 url 模式均是如此,区别在于 entry 模式多了一步 fetch 拉 html 字符串并解析 js、css 依赖,而 url 模式只需要制定子应用的脚本和样式依赖即可)。

而 qiankun 则采用了另一种办法,首先同理会先通过 fetch 获取外联的 js 字符串。源码位置

export function getExternalScripts(
  scripts,
  fetch = defaultFetch,
  errorCallback = () => {}
) {
  const fetchScript = (scriptUrl) => {
    // 通过fetch 获取js资源,如果有缓存从缓存拿
    // 略
  };

  return Promise.all(
    scripts.map((script) => {
      if (typeof script === "string") {
        if (isInlineCode(script)) {
          // 获取内联的js code
          return getInlineCode(script);
        } else {
          // fetch 获取外联的js code
          return fetchScript(script);
        }
      } else {
        // 上面说过了,processTpl 方法会处理各种js css资源,其中对于需要异步执行的js资源会打上async标识
        // 打上async标识的js资源,会使用requestIdleCallback延迟执行
        const { src, async } = script;
        if (async) {
          return {
            src,
            async: true,
            content: new Promise((resolve, reject) =>
              requestIdleCallback(() => fetchScript(src).then(resolve, reject))
            ),
          };
        }

        return fetchScript(src);
      }
    })
  );
}

接下来会创建一个匿名自执行函数包裹住获取到的 js 字符串,最后通过 eval 去创建一个执行上下文执行 js 代码。源码位置

function getExecutableScript(scriptSrc, scriptText, proxy, strictGlobal) {
  const sourceUrl = isInlineCode(scriptSrc)
    ? ""
    : `//# sourceURL=${scriptSrc}\n`;

  window.proxy = proxy;
  return strictGlobal
    ? `;(function(window, self){with(window){;${scriptText}\n${sourceUrl}}}).bind(window.proxy)(window.proxy, window.proxy);`
    : `;(function(window, self){;${scriptText}\n${sourceUrl}}).bind(window.proxy)(window.proxy, window.proxy);`;
}

默认不会通过 with 劫持 window 对象的作用域,我们通过 webpack 打包后的 bundle 是会带着 with 劫持 window 对象的,为什么需要 with 劫持 window 的作用域,之后的的 sandbox 分析我会详细介绍,这里我先简单讲下,qiankun 的 sandbox 实现原理是通过 Proxy 代理劫持 window 去做的,那么就会出现一个问题,window.xxx 这样形式的属性会被劫持掉,但是直接声明的全局对象不会被劫持。

那么,就需要用到 with 去劫持 window 的作用域了。

因为 qiankun 是创建自己的执行上下文执行子应用的 js,因此在加载后的子应用中是看不到 js 资源引用的,仅有一个资源被执行替换的标识。

在 qiankun 中,通过调用 import-html-entry 导出的 execScript 函数,可以获取到子应用导出的生命周期勾子。

// get the lifecycle hooks from module exports
// 上面说过的eval包裹js代码返回可执行的bundle就是execScripts主要做的事
const scriptExports: any = await execScripts(global, !singular);
const { bootstrap, mount, unmount, update } = getLifecyclesFromExports(
  scriptExports,
  appName,
  global
);

总结

到此,子应用加载的核心逻辑就说完了,具体的实现以及一些其他细致逻辑的处理大家可以去看源码。下一片文章我将从 quankun 的源码像大家介绍微前端沙箱的实现。

推荐阅读

查看原文

赞 2 收藏 2 评论 0

玩弄心里的鬼 发布了文章 · 11月1日

解密微前端:"巨石应用"的诞生

随着我们中后台系统的复杂,往往会遇到多个团队独立维护的子应用接入统一的主应用中,这些子应用往往独立开发、独立部署、彼此完全解耦,这时候往常的单一应用无法满足业务的增长需求。而微前端便是用来解决随着时间的推移业务复杂度的提升,某个单应用演变为难以维护的“巨石应用”。

这便文章并不是一个源码解析以及上手教程的文章,我希望从一个宏观的角度介绍下微前端并且简单聊一下微前端在我们现在项目中的一些思考。

先聊聊背景

我一直认为抛离业务的技术改造都是没有太大价值并且很难走远的。这里我先简单介绍下我对目前项目所做的一个架构演变以及一些简单思考。我目前所处一个大型的 B 端项目团队,系统根据业务域划分的子应用有几十个,系统 PV 百万+,所以在这样一个庞大的系统中我们的系统架构也经历了以下一些变化。

模版架构

在我刚加入团队的时候,我们的系统还是一个前后端未完全分离的架构,模版提供了一个入口挂载根节点,前端在根节点上渲染 React / Vue 应用。

不难发现这样的架构存在一定的弊端:

  • 后端解析模版,挂载模版;
  • 后端管控路由,前端失去了对路由的掌控权,并且多个团队路由规则管控较难;
  • 每一次前端发布需要先发布静态资源再发布模版,发布繁琐容易出错;
  • 这样的开发方式相对来说较为古老,对于喜欢捣鼓新事物的前端来说很难激发热情;

但是值得庆幸的是,这时候我们的系统已经有一定的分治思想了,主应用(这个时候应该说是 layout 层)和子应用单独挂载在不同的模版片段上,这也为后面的 iframe 和微前端改造减少了不少工作量

其实,这样的架构也有一定微前端的影子:) 。

iframe 架构

后面随着后端微服务化的转型,后端已经不去关心路由的管控和页面的挂载,转而提供更加原子化的微服务。而对我们前端来说:

  • 需要不依赖后端模版;
  • 需要管控路由并制定统一的路由规则;
  • 主子应用的沙箱隔离;
  • 尽量对子业务的改动做到最小化,很多业务包袱很重;
  • 快速落地并实现高可用(没办法,开发时间永远是一个打败很多选项的因素...);

但是随着 iframe 架构的落地以及后续迭代的进行,我们也发现了 iframe 方案的一些弊端:

  • 通信方式简单,简单的 postmessage API 并不能满足业务的需求;
  • 样式割裂,iframe 会导致诸如 Dialog 这样子的全局蒙层仅在 ifame 区块内展示,使我们系统更像是“拼凑”出来的;
  • 性能瓶颈,路由的切换会导致 iframe 内子应用的重新加载,性能堪忧;
  • 跨域问题,chrome80 的 samesite 策略会导致 iframe 方案的跨域 cookie 无法带给后端;

微前端架构

最后在今年中旬的时候我这边将 iframe 架构升级到了微前端 + iframe 并存的架构,并开发落地了一系列微前端相关的开发工具链(喜大普奔...)。

so,为什么不是 iframe?

在我看来,微前端是一个思想,是一种开发模式和架构的演变,诸如 qiankun、icestark 等框架也仅是微前端实现的落地,合理的 iframe 实现未尝不算是一种微前端实现的落地。我们原来的 iframe 架构设计一定程度上也算是一种微前端思想,并且在我看来,iframe 对于这种跨团队的中后台巨无霸项目依然有着天然的优势,理由如下:

  • 框架无关:iframe 只加载部署完应用链接;
  • 独立沙箱:iframe 拥有浏览器的独立沙箱,子应用和主应用完全不用考虑 js & css 污染问题;
  • 开发简单:仅仅一个 iframe 标签;

但是,与之而来的便是 iframe 的一些痛点:

  • UI 体验不好:iframe 仅在制定的渲染块内渲染,而子应用的一些类似于带遮罩层的 Modal 等全局的 UI 组件只会在 iframe 内呈现;
  • 通信困难:iframe 强大的沙箱机制带来的副作用就是父子应用通信困难,仅仅一个 postmessage API,跨域 cookie、Promise 等等都难以实现;
  • 路由丢失:子应用的 url 改变无法及时同步父应用,随着页面的刷新,子应用的路由状态丢失;
  • 加载时间长:每次子应用进入都是一次浏览器上下文重建、资源重新加载的过程;

尽管以上这些痛点或多或少的配合着一些 hack 的工具以及开发规范都有一定的解决方案,但是有更好的选择为什么不尝试呢:)

那么,什么是微前端?

这里我不去讲概念,道理大家都懂,概念一搜全都是。例如微前端很官方的诠释:

Techniques, strategies and recipes for building a modern web app with multiple teams that can ship features independently

就像巨石应用并不是一蹴而就的,接下来我来通过一个巨石应用的演变来向大家介绍我理解的微前端。

单个页面

起初我们的系统可能仅有一个业务模块。路由硬编码在项目里,layout 层和业务子系统也写在一起。

多个页面

随着业务的增长,我们的系统接入了更多的业务模块,这个时候其实通过一定的路由配置和多页的配置,项目也还算是没太大问题。

但是这个时候需要引起警觉,如果再接入了更多的业务模块还停留在当前的模式下,项目该怎么维护?

更多的页面?巨石应用!

随着业务更进一步的增长,接入的业务模块越来越多,不仅我们以导航维度扩充的子应用增多,甚至诸如首页这样子的页面上也会有归属于多个业务域的区块。总结起来就会分为两类场景:

  • 单实例:一个或多个页面对应一个子应用,同一时刻仅有一个子应用渲染展示;
  • 多实例:一个页面包含多个子业务应用区块,同一时候有多个实例渲染展示;

这时如果没有很好的处理和子系统的拆分,那么我们的应用就会变为巨石应用...

  • 开发迭代并上线一个巨石应用比上线多个子应用要蛋疼的多...
  • 多人(多团队)维护一个项目,你永远不知道别人做了啥或者将要做啥...
  • 往往这样子的应用,大家都是做加法而不去做减法,使得项目越来越大,无用代码越来越多...

微前端,分而治之!

这时候我们往往将系统根据业务域的划分拆分成不同的子应用,而承载这些子应用的 layout 层我们拆分为主应用,各个子应用独立开发独立发布,并且由不同的业务团队维护,以此来解决复杂的单体应用带来的各种开发维护问题。

可以看出,微前端便是采取分治的思想来避免单体应用演变成巨石应用的。

在我看来,在微前端的思想中,重点强调了几点:

  • 独立性:微前端的主应用以及各个子应用独立开发、独立部署,并且在一定程度上微前端子应用可以独立于主应用单独运行;
  • 沙箱隔离:子应用有自己单独的运行时,各个子应用之间的状态不被共享;
  • 框架无关:子应用完全可以采取不同的框架进行开发,因为现有微前端框架实现中,主应用仅是加载子应用构建后的 bundle;

我们的选择

现在市面上的微前端框架有很多,例如阿里内部就有 icestack、qiankun 两大比较成熟的开源微前端框架,以及社区上 singleSPA 等。那么我们该如何选择适合我们项目的微前端框架呢,这里我简单罗列了我在选择微前端框架时候的一些思考:

  • 改动成本:任何业务团队都不能逃避这个话题,你怎么说服产品和老板需要这么长时间的技术改造(还存在一定风险)、你怎么说服子应用方配合改造(相信我,子应用方不会愿意去做 > 1 天改造的...);
  • 沙箱隔离:这个就不多说了,很多子应用会集成自己的埋点&监控体系,这样的体系往往会挂载或者劫持全局变量的;
  • 路由不变性:在我们 iframe 架构中,已经有一套比较完善的路由规则,那么如何保证路由不变的前提下去做架构升级呢?
  • 通信不变性:同上,在我们的 iframe 架构中,已经有一套比较成熟的通信工具,那么能不能在保证 API 不变的前提下完成微前端的通信呢?或者干脆微前端可以服用 iframe 的通信工具?
  • 兼容 iframe:这么庞大的系统不是一下子就迁移到微前端的,并且我们也不打算抛弃 iframe 架构(不肯能全部业务都迁移的...)。那么,如何保证两套架构的共存?更进一步,如何保证两套架构共存然后还能共享一套路由规则?
  • 灰度控制:子应用的大版本发布往往会伴随着灰度控制,那么如何保证微前端接入后也能有完整的灰度流程?是做一个版本管控的工具还是直接使用 htmlEntry?
  • “远古”应用接入:远古应用保持 iframe 嵌入就最好了,那么我们能不能贪一点也能支持“远古巨龙”的丝滑接入呢?

最后,结合上面的一些思考以及我们现在的系统架构,我选择了 qiankun 来落地我们的微前端方案。并且为了保证微前端接入以及版本管控的便捷,我们落地了一些微前端的生态链工具。

结语

以上便是我在做微前端改造时候结合业务系统的一些思考,如果有不对的地方欢迎指正。之后我也会输出 qiankun 的源码解析以及我所做的一些微前端工具的原理分析等文章(撒花...)。

查看原文

赞 4 收藏 3 评论 0

玩弄心里的鬼 赞了文章 · 9月18日

万字长文+图文并茂+全面解析微前端框架 qiankun 源码 - qiankun 篇

写在开头

微前端系列文章:

本系列其他文章计划一到两个月内完成,点个 关注 不迷路。

计划如下:

  • 生命周期篇;
  • IE 兼容篇;
  • 生产环境部署篇;
  • 性能优化、缓存方案篇;

引言

本文将针对微前端框架 qiankun 的源码进行深入解析,在源码讲解之前,我们先来了解一下什么是 微前端

微前端 是一种类似于微服务的架构,它将微服务的理念应用于浏览器端,即将单页面前端应用由单一的单体应用转变为多个小型前端应用聚合为一的应用。各个前端应用还可以独立开发、独立部署。同时,它们也可以在共享组件的同时进行并行开发——这些组件可以通过 NPM 或者 Git Tag、Git Submodule 来管理。

qiankun(乾坤) 就是一款由蚂蚁金服推出的比较成熟的微前端框架,基于 single-spa 进行二次开发,用于将 Web 应用由单一的单体应用转变为多个小型前端应用聚合为一的应用。(见下图)

qiankun

那么,话不多说,我们的源码解析正式开始。

初始化全局配置 - start(opts)

我们从两个基础 API - registerMicroApps(apps, lifeCycles?) - 注册子应用start(opts?) - 启动主应用 开始,由于 registerMicroApps 函数中设置的回调函数较多,并且读取了 start 函数中设置的初始配置项,所以我们从 start 函数开始解析。

我们从 start 函数开始解析(见下图):

qiankun

我们对 start 函数进行逐行解析:

  • 第 196 行:设置 window__POWERED_BY_QIANKUN__ 属性为 true,在子应用中使用 window.__POWERED_BY_QIANKUN__ 值判断是否运行在主应用容器中。
  • 第 198~199 行:设置配置参数(有默认值),将配置参数存储在 importLoaderConfiguration 对象中;
  • 第 201~203 行:检查 prefetch 属性,如果需要预加载,则添加全局事件 single-spa:first-mount 监听,在第一个子应用挂载后预加载其他子应用资源,优化后续其他子应用的加载速度。
  • 第 205 行:根据 singularMode 参数设置是否为单实例模式。
  • 第 209~217 行:根据 jsSandbox 参数设置是否启用沙箱运行环境,旧版本需要关闭该选项以兼容 IE。(新版本在单实例模式下默认支持 IE,多实例模式依然不支持 IE)。
  • 第 222 行:调用了 single-spastartSingleSpa 方法启动应用,这个在 single-spa 篇我们会单独剖析,这里可以简单理解为启动主应用。

从上面可以看出,start 函数负责初始化一些全局设置,然后启动应用。这些初始化的配置参数有一部分将在 registerMicroApps 注册子应用的回调函数中使用,我们继续往下看。

注册子应用 - registerMicroApps(apps, lifeCycles?)

registerMicroApps 函数的作用是注册子应用,并且在子应用激活时,创建运行沙箱,在不同阶段调用不同的生命周期钩子函数。(见下图)

qiankun

从上面可以看出,在 第 70~71 行registerMicroApps 函数做了个处理,防止重复注册相同的子应用。

第 74 行 调用了 single-sparegisterApplication 方法注册了子应用。

我们直接来看 registerApplication 方法,registerApplication 方法是 single-spa 中注册子应用的核心函数。该函数有四个参数,分别是

  • name(子应用的名称)
  • 回调函数(activeRule 激活时调用)
  • activeRule(子应用的激活规则)
  • props(主应用需要传递给子应用的数据)

这些参数都是由 single-spa 直接实现,这里可以先简单理解为注册子应用(这个我们会在 single-spa 篇展开说)。在符合 activeRule 激活规则时将会激活子应用,执行回调函数,返回一些生命周期钩子函数(见下图)。

注意,这些生命周期钩子函数属于 single-spa,由 single-spa 决定在何时调用,这里我们从函数名来简单理解。(bootstrap - 初始化子应用,mount - 挂载子应用,unmount - 卸载子应用)

qiankun

如果你还是觉得有点懵,没关系,我们通过一张图来帮助理解。(见下图)

qiankun

获取子应用资源 - import-html-entry

我们从上面分析可以看出,qiankunregisterMicroApps 方法中第一个入参 apps - Array<RegistrableApp<T>> 有三个参数 name、activeRule、props 都是交给 single-spa 使用,还有 entryrender 参数还没有用到。

我们这里需要关注 entry(子应用的 entry 地址)render(子应用被激活时触发的渲染规则) 这两个还没有用到的参数,这两个参数延迟到 single-spa 子应用激活后的回调函数中执行。

那我们假设此时我们的子应用已激活,我们来看看这里做了什么。(见下图)

qiankun

从上图可以看出,在子应用激活后,首先在 第 81~84 行 处使用了 import-html-entry 库从 entry 进入加载子应用,加载完成后将返回一个对象(见下图)

qiankun

我们来解释一下这几个字段

字段解释
template将脚本文件内容注释后的 html 模板文件
assetPublicPath资源地址根路径,可用于加载子应用资源
getExternalScripts方法:获取外部引入的脚本文件
getExternalStyleSheets方法:获取外部引入的样式表文件
execScripts方法:执行该模板文件中所有的 JS 脚本文件,并且可以指定脚本的作用域 - proxy 对象

我们先将 template 模板getExternalScriptsgetExternalStyleSheets 函数的执行结果打印出来,效果如下(见下图):

qiankun

从上图我们可以看到我们外部引入的三个 js 脚本文件,这个模板文件没有外部 css 样式表,对应的样式表数组也为空。

然后我们再来分析 execScripts 方法,该方法的作用就是指定一个 proxy(默认是 window)对象,然后执行该模板文件中所有的 JS,并返回 JS 执行后 proxy 对象的最后一个属性(见下图 1)。在微前端架构中,这个对象一般会包含一些子应用的生命周期钩子函数(见下图 2),主应用可以通过在特定阶段调用这些生命周期钩子函数,进行挂载和销毁子应用的操作。

qiankun

qiankun

qiankunimportEntry 函数中还传入了配置项 getTemplate,这个其实是对 html 目标文件的二次处理,这里就不作展开了,有兴趣的可以自行去了解一下。

主应用挂载子应用 HTML 模板

我们回到 qiankun 源码部分继续看(见下图)

qiankun

从上图看出,在 第 85~87 行 处,先对单实例进行检测。在单实例模式下,新的子应用挂载行为会在旧的子应用卸载之后才开始。

第 88 行 中,执行注册子应用时传入的 render 函数,将 HTML Templateloading 作为入参,render 函数的内容一般是将 HTML 挂载在指定容器中(见下图)。

qiankun

在这个阶段,主应用已经将子应用基础的 HTML 结构挂载在了主应用的某个容器内,接下来还需要执行子应用对应的 mount 方法(如 Vue.$mount)对子应用状态进行挂载。

此时页面还可以根据 loading 参数开启一个类似加载的效果,直至子应用全部内容加载完成。

沙箱运行环境 - genSandbox

我们回到 qiankun 源码部分继续看,此时还是子应用激活时的回调函数部分(见下图)

qiankun

第 90~98 行qiankun 比较核心的部分,也是几个子应用之间状态独立的关键,那就是 js 的沙箱运行环境。如果关闭了 useJsSandbox 选项,那么所有子应用的沙箱环境都是 window,就很容易对全局状态产生污染。

我们进入到 genSandbox 内部,看看 qiankun 是如何创建的 (JS)沙箱运行环境。(见下图)

qiankun

从上图可以看出 genSandbox 内部的沙箱主要是通过是否支持 window.Proxy 分为 LegacySandboxSnapshotSandbox 两种。

扩展阅读:多实例还有一种 ProxySandbox 沙箱,这种沙箱模式目前看来是最优方案。由于其表现与旧版本略有不同,所以暂时只用于多实例模式。

ProxySandbox 沙箱稳定之后可能会作为单实例沙箱使用。

LegacySandbox

我们先来看看 LegacySandbox 沙箱是怎么进行状态隔离的(见下图)

qiankun

我们来分析一下 LegacySandbox 类的几个属性:

字段解释
addedPropsMapInSandbox记录沙箱运行期间新增的全局变量
modifiedPropsOriginalValueMapInSandbox记录沙箱运行期间更新的全局变量
currentUpdatedPropsValueMap记录沙箱运行期间操作过的全局变量。上面两个 Map 用于 关闭沙箱 时还原全局状态,而 currentUpdatedPropsValueMap 是在 激活沙箱 时还原沙箱的独立状态
name沙箱名称
proxy代理对象,可以理解为子应用的 global/window 对象
sandboxRunning当前沙箱是否在运行中
active激活沙箱,在子应用挂载时启动
inactive关闭沙箱,在子应用卸载时启动
constructor构造函数,创建沙箱环境

我们现在从 window.Proxysetget 属性来详细讲解 LegacySandbox 是如何实现沙箱运行环境的。(见下图)

qiankun

注意:子应用沙箱中的 proxy 对象(第 62 行)可以简单理解为子应用的 window 全局对象(代码如下),子应用对全局属性的操作就是对该 proxy 对象属性的操作,带着这份理解继续往下看吧。
// 子应用脚本文件的执行过程:
eval(
  // 这里将 proxy 作为 window 参数传入
  // 子应用的全局对象就是该子应用沙箱的 proxy 对象
  (function(window) {
    /* 子应用脚本文件内容 */
  })(proxy)
);

第 65~72 行中,当调用 set 向子应用 proxy/window 对象设置属性时,所有的属性设置和更新都会先记录在 addedPropsMapInSandboxmodifiedPropsOriginalValueMapInSandbox 中,然后统一记录到
currentUpdatedPropsValueMap 中。

第 73 行 中修改全局 window 的属性,完成值的设置。

当调用 get 从子应用 proxy/window 对象取值时,会直接从 window 对象中取值。对于非构造函数的取值将会对 this 指针绑定到 window 对象后,再返回函数。

LegacySandbox 的沙箱隔离是通过激活沙箱时还原子应用状态,卸载时还原主应用状态(子应用挂载前的全局状态)实现的,具体实现如下(见下图)。

qiankun

从上图可以看出:

  • 第 37 行:在激活沙箱时,沙箱会通过 currentUpdatedPropsValueMap 查询到子应用的独立状态池(沙箱可能会激活多次,这里是沙箱曾经激活期间被修改的全局变量),然后还原子应用状态。
  • 第 44~45 行:在关闭沙箱时,通过 addedPropsMapInSandbox 删除在沙箱运行期间新增的全局变量,通过 modifiedPropsOriginalValueMapInSandbox 还原沙箱运行期间被修改的全局变量,从而还原到子应用挂载前的状态。

从上面的分析可以得知,LegacySandbox 的沙箱隔离机制利用快照模式实现,我们画一张图来帮助理解(见下图)

qiankun

多实例沙箱 - ProxySandbox

ProxySandbox 是一种新的沙箱模式,目前用于多实例模式的状态隔离。在稳定后以后可能会成为 单实例沙箱,我们来看看 ProxySandbox 沙箱是怎么进行状态隔离的(见下图)

qiankun

我们来分析一下 ProxySandbox 类的几个属性:

字段解释
updateValueMap记录沙箱中更新的值,也就是每个子应用中独立的状态池
name沙箱名称
proxy代理对象,可以理解为子应用的 global/window 对象
sandboxRunning当前沙箱是否在运行中
active激活沙箱,在子应用挂载时启动
inactive关闭沙箱,在子应用卸载时启动
constructor构造函数,创建沙箱环境

我们现在从 window.Proxysetget 属性来详细讲解 ProxySandbox 是如何实现沙箱运行环境的。(见下图)

qiankun

注意:子应用沙箱中的 proxy 对象可以简单理解为子应用的 window 全局对象(代码如下),子应用对全局属性的操作就是对该 proxy 对象属性的操作,带着这份理解继续往下看吧。
// 子应用脚本文件的执行过程:
eval(
  // 这里将 proxy 作为 window 参数传入
  // 子应用的全局对象就是该子应用沙箱的 proxy 对象
  (function(window) {
    /* 子应用脚本文件内容 */
  })(proxy)
);

当调用 set 向子应用 proxy/window 对象设置属性时,所有的属性设置和更新都会命中 updateValueMap,存储在 updateValueMap 集合中(第 38 行),从而避免对 window 对象产生影响(旧版本则是通过 diff 算法还原 window 对象状态快照,子应用之间的状态是隔离的,而父子应用之间 window 对象会有污染)。

当调用 get 从子应用 proxy/window 对象取值时,会优先从子应用的沙箱状态池 updateValueMap 中取值,如果没有命中才从主应用的 window 对象中取值(第 49 行)。对于非构造函数的取值将会对 this 指针绑定到 window 对象后,再返回函数。

如此一来,ProxySandbox 沙箱应用之间的隔离就完成了,所有子应用对 proxy/window 对象值的存取都受到了控制。设置值只会作用在沙箱内部的 updateValueMap 集合上,取值也是优先取子应用独立状态池(updateValueMap)中的值,没有找到的话,再从 proxy/window 对象中取值。

相比较而言,ProxySandbox 是最完备的沙箱模式,完全隔离了对 window 对象的操作,也解决了快照模式中子应用运行期间仍然会对 window 造成污染的问题。

我们对 ProxySandbox 沙箱画一张图来加深理解(见下图)

qiankun

SnapshotSandbox

在不支持 window.Proxy 属性时,将会使用 SnapshotSandbox 沙箱,我们来看看其内部实现(见下图)

qiankun

我们来分析一下 SnapshotSandbox 类的几个属性:

字段解释
name沙箱名称
proxy代理对象,此处为 window 对象
sandboxRunning当前沙箱是否激活
windowSnapshotwindow 状态快照
modifyPropsMap沙箱运行期间被修改过的 window 属性
constructor构造函数,激活沙箱
active激活沙箱,在子应用挂载时启动
inactive关闭沙箱,在子应用卸载时启动

SnapshotSandbox 的沙箱环境主要是通过激活时记录 window 状态快照,在关闭时通过快照还原 window 对象来实现的。(见下图)

qiankun

我们先看 active 函数,在沙箱激活时,会先给当前 window 对象打一个快照,记录沙箱激活前的状态(第 38~40 行)。打完快照后,函数内部将 window 状态通过 modifyPropsMap 记录还原到上次的沙箱运行环境,也就是还原沙箱激活期间(历史记录)修改过的 window 属性。

在沙箱关闭时,调用 inactive 函数,在沙箱关闭前通过遍历比较每一个属性,将被改变的 window 对象属性值(第 54 行)记录在 modifyPropsMap 集合中。在记录了 modifyPropsMap 后,将 window 对象通过快照 windowSnapshot 还原到被沙箱激活前的状态(第 55 行),相当于是将子应用运行期间对 window 造成的污染全部清除。

SnapshotSandbox 沙箱就是利用快照实现了对 window 对象状态隔离的管理。相比较 ProxySandbox 而言,在子应用激活期间,SnapshotSandbox 将会对 window 对象造成污染,属于一个对不支持 Proxy 属性的浏览器的向下兼容方案。

我们对 SnapshotSandbox 沙箱画一张图来加深理解(见下图)

qiankun

挂载沙箱 - mountSandbox

qiankun

我们继续回到这张图,genSandbox 函数不仅返回了一个 sandbox 沙箱,还返回了一个 mountunmount 方法,分别在子应用挂载时和卸载时的时候调用。

我们先看看 mount 函数内部(见下图)

qiankun

首先,在 mount 内部先激活了子应用沙箱(第 26 行),在沙箱启动后开始劫持各类全局监听(第 27 行),我们这里重点看看 patchAtMounting 内部是怎么实现的。(见下图)

qiankun

patchAtMounting 内部调用了下面四个函数:

  • patchTimer(计时器劫持)
  • patchWindowListener(window 事件监听劫持)
  • patchHistoryListener(window.history 事件监听劫持)
  • patchDynamicAppend(动态添加 Head 元素事件劫持)

上面四个函数实现了对 window 指定对象的统一劫持,我们可以挑一些解析看看其内部实现。

计时器劫持 - patchTimer

我们先来看看 patchTimer 对计时器的劫持(见下图)

qiankun

从上图可以看出,patchTimer 内部将 setInterval 进行重载,将每个启用的定时器的 intervalId 都收集起来(第 23~24 行),以便在子应用卸载时调用 free 函数将计时器全部清除(见下图)。

qiankun

我们来看看在子应用加载时的 setInterval 函数验证即可(见下图)

qiankun

从上图可以看出,在进入子应用时,setInterval 已经被替换成了劫持后的函数,防止全局计时器泄露污染。

动态添加样式表和脚本文件劫持 - patchDynamicAppend

patchWindowListenerpatchHistoryListener 的实现都与 patchTimer 实现类似,这里就不作复述了。

我们需要重点对 patchDynamicAppend 函数进行解析,这个函数的作用是劫持对 head 元素的操作(见下图)

qiankun

从上图可以看出,patchDynamicAppend 主要是对动态添加的 style 样式表和 script 标签做了处理。

我们先看看对 style 样式表的处理(见下图)

qiankun

从上图可以看出,主要的处理逻辑在 第 68~74 行,如果当前子应用处于激活状态(判断子应用的激活状态主要是因为:当主应用切换路由时可能会自动添加动态样式表,此时需要避免主应用的样式表被添加到子应用 head 节点中导致出错),那么动态 style 样式表就会被添加到子应用容器内(见下图),在子应用卸载时样式表也可以和子应用一起被卸载,从而避免样式污染。同时,动态样式表也会存储在 dynamicStyleSheetElements 数组中,在后面还会提到其用处。

qiankun

我们再来看看对 script 脚本文件的处理(见下图)

qiankun

对动态 script 脚本文件的处理较为复杂一些,我们也来解析一波:

第 83~101 行 处对外部引入的 script 脚本文件使用 fetch 获取,然后使用 execScripts 指定 proxy 对象(作为 window 对象)后执行脚本文件内容,同时也触发了 loaderror 两个事件。

第 103~106 行 处将注释后的脚本文件内容以注释的形式添加到子应用容器内。

第 109~113 行 是对内嵌脚本文件的执行过程,就不作复述了。

我们可以看出,对动态添加的脚本进行劫持的主要目的就是为了将动态脚本运行时的 window 对象替换成 proxy 代理对象,使子应用动态添加的脚本文件的运行上下文也替换成子应用自身。

HTMLHeadElement.prototype.removeChild 的逻辑就是多加了个子应用容器判断,其他无异,就不展开说了。

最后我们来看看 free 函数(见下图)

qiankun

这个 free 函数与其他的 patches(劫持函数) 实现不太一样,这里缓存了一份 cssRules,在重新挂载的时候会执行 rebuild 函数将其还原。这是因为样式元素 DOM 从文档中删除后,浏览器会自动清除样式元素表。如果不这么做的话,在重新挂载时会出现存在 style 标签,但是没有渲染样式的问题。

卸载沙箱 - unmountSandbox

我们再回到 mount 函数本身(见下图)

qiankun

从上图可以看出,在 patchAtMounting 函数中劫持了各类全局监听,并返回了解除劫持的 free 函数。在卸载应用时调用 free 函数解除这些全局监听的劫持行为(见下图)

qiankun

从上图可以看到 sideEffectsRebuildersfree 后被返回,在 mount 的时候又将被调用 rebuild 重建动态样式表。这块环环相扣,是稍微有点绕,没太看明白的同学可以翻上去再看一遍。

到这里,qiankun 的最核心部分-沙箱机制,我们就已经解析完毕了,接下来我们继续剖析别的部分。

在这里我们画一张图,对沙箱的创建过程进行一个总梳理(见下图)

qiankun

注册内部生命周期函数

在创建好了沙箱环境后,在 第 100~106 行 注册了一些内部生命周期函数(见下图)

qiankun

在上图中,第 106 行mergeWith 方法的作用是将内置的生命周期函数与传入的 lifeCycles 生命周期函数。

这里的 lifeCycles 生命周期函数指的是全子应用共享的生命周期函数,可用于执行多个子应用间相同的逻辑操作,例如 加载效果 之类的。(见下图)

qiankun

除了外部传入的生命周期函数外,我们还需要关注 qiankun 内置的生命周期函数做了些什么(见下图)

qiankun

我们对上图的代码进行逐一解析:

  • 第 13~15 行:在加载子应用前 beforeLoad(只会执行一次)时注入一个环境变量,指示了子应用的 public 路径。
  • 第 17~19 行:在挂载子应用前 beforeMount(可能会多次执行)时可能也会注入该环境变量。
  • 第 23~30 行:在卸载子应用前 beforeUnmount 时将环境变量还原到原始状态。

通过上面的分析我们可以得出一个结论,我们可以在子应用中获取该环境变量,将其设置为 __webpack_public_path__ 的值,从而使子应用在主应用中运行时,可以匹配正确的资源路径。(见下图)

qiankun

触发 beforeLoad 生命周期钩子函数

在注册完了生命周期函数后,立即触发了 beforeLoad 生命周期钩子函数(见下图)

qiankun

从上图可以看出,在 第 108 行 中,触发了 beforeLoad 生命周期钩子函数。

随后,在 第 110 行 执行了 import-html-entryexecScripts 方法。指定了脚本文件的运行沙箱(jsSandbox),执行完子应用的脚本文件后,返回了一个对象,对象包含了子应用的生命周期钩子函数(见下图)。

qiankun

第 112~121 行 对子应用的生命周期钩子函数做了个检测,如果在子应用的导出对象中没有发现生命周期钩子函数,会在沙箱对象中继续查找生命周期钩子函数。如果最后没有找到生命周期钩子函数则会抛出一个错误,所以我们的子应用一定要有 bootstrap, mount, unmount 这三个生命周期钩子函数才能被 qiankun 正确嵌入到主应用中。

这里我们画一张图,对子应用挂载前的初始化过程做一个总梳理(见下图)

qiankun

进入到 mount 挂载流程

在一些初始化配置(如 子应用资源、运行沙箱环境、生命周期钩子函数等等)准备就绪后,qiankun 内部将其组装在一起,返回了三个函数作为 single-spa 内部的生命周期函数(见下图)

qiankun

single-spa 内部的逻辑我们后面再展开说,这里我们可以简单理解为 single-spa 内部的三个生命周期钩子函数:

  • bootstrap:子应用初始化时调用,只会调用一次;
  • mount:子应用挂载时调用,可能会调用多次;
  • unmount:子应用卸载时调用,可能会调用多次;

我们可以看出,在 bootstrap 阶段调用了子应用暴露的 bootstrap 生命周期函数。

我们这里对 mount 阶段进行展开,看看在子应用 mount 阶段执行了哪些函数(见下图)

qiankun

我们进行逐行解析:

  • 第 127~133 行:对单实例模式进行检测。在单实例模式下,新的子应用挂载行为会在旧的子应用卸载之后才开始。(由于这里是串行顺序执行,所以如果某一处发生阻塞的话,会阻塞所有后续的函数执行)
  • 第 134 行:执行注册子应用时传入的 render 函数,将 HTML Templateloading 作为入参。这里一般是在发生了一次 unmount 后,再次进行 mount 挂载行为时将 HTML 挂载在指定容器中(见下图)

    由于初始化的时候已经调用过一次 render,所以在首次调用 mount 时可能已经执行过一次 render 方法。

    在下面的代码中也有对重复挂载的情况进行判断的语句 - if (frame.querySelector("div") === null,防止重复挂载子应用。

qiankun

  • 第 135 行:触发了 beforeMount 全局生命周期钩子函数;
  • 第 136 行:挂载沙箱,这一步中激活了对应的子应用沙箱,劫持了部分全局监听(如 setInterval)。此时开始子应用的代码将在沙箱中运行。(反推可知,在 beforeMount 前的部分全局操作将会对主应用造成污染,如 setInterval
  • 第 137 行:触发子应用的 mount 生命周期钩子函数,在这一步通常是执行对应的子应用的挂载操作(如 ReactDOM.render、Vue.$mount。(见下图)

qiankun

  • 第 138 行:再次调用 render 函数,此时 loading 参数为 false,代表子应用已经加载完成。
  • 第 139 行:触发了 afterMount 全局生命周期钩子函数;
  • 第 140~144 行:在单实例模式下设置 prevAppUnmountedDeferred 的值,这个值是一个 promise,在当前子应用卸载时才会被 resolve,在该子应用运行期间会阻塞其他子应用的挂载动作(第 134 行);

我们在上面很详细的剖析了整个子应用的 mount 挂载流程,如果你还没有搞懂的话,没关系,我们再画一个流程图来帮助理解。(见下图)

qiankun

进入到 unmount 卸载流程

我们刚才梳理了子应用的 mount 挂载流程,我们现在就进入到子应用的 unmount 卸载流程。在子应用激活阶段, activeRule 未命中时将会触发 unmount 卸载行为,具体的行为如下(见下图)

qiankun

从上图我们可以看出,unmount 卸载流程要比 mount 简单很多,我们直接来梳理一下:

  • 第 148 行:触发了 beforeUnmount 全局生命周期钩子函数;
  • 第 149 行:这里与 mount 流程的顺序稍微有点不同,这里先执行了子应用的 unmount 生命周期钩子函数,保证子应用仍然是运行在沙箱内,避免造成状态污染。在这里一般是对子应用的一些状态进行清理和卸载操作。(如下图,销毁了刚才创建的 vue 实例)

qiankun

  • 第 150 行:卸载沙箱,关闭了沙箱的激活状态。
  • 第 151 行:触发了 afterUnmount 全局生命周期钩子函数;
  • 第 152 行:触发 render 方法,并且传入的 appContent 为空字符串,此处可以清空主应用容器内的内容。
  • 第 153~156 行:当前子应用卸载完成后,在单实例模式下触发 prevAppUnmountedDeferred.resolve(),使其他子应用的挂载行为得以继续进行,不再阻塞。

我们对 unmount 卸载流程也画一张图,帮助大家理解(见下图)。

qiankun

总结

到这里,我们对 qiankun 框架的总流程梳理就差不多了。这里应该做个总结,大家看了这么多文字,估计大家也看累了,最后用一张图对 qiankun 的总流程进行总结吧。

qiankun

彩蛋

qiankun

展望

传统的云控制台应用,几乎都会面临业务快速发展之后,单体应用进化成巨石应用的问题。我们要如何维护一个巨无霸中台应用?

上面这个问题引出了微前端架构理念,所以微前端的概念也越来越火,我们团队最近也在尝试转型微前端架构。

工欲善其事必先利其器,所以本文针对 qiankun 的源码进行解读,在分享知识的同时也是帮助自己理解。

这是我们团队对微前端架构的最佳实践(见下图),如果有需求的话,可以在评论区留言,我们会考虑出一篇《微前端框架 qiankun 最佳实践》来帮助大家搭建一套微前端架构。

架构图

最后一件事

如果您已经看到这里了,希望您还是点个赞再走吧~

您的点赞是对作者的最大鼓励,也可以让更多人看到本篇文章!

如果觉得本文对您有帮助,请帮忙在 github 上点亮 star 鼓励一下吧!

personal

查看原文

赞 101 收藏 54 评论 27

玩弄心里的鬼 赞了文章 · 4月30日

typescript 高级技巧

用了一段时间的 typescript 之后,深感中大型项目中 typescript 的必要性,它能够提前在编译期避免许多 bug,如很恶心的拼写问题。而越来越多的 package 也开始使用 ts,学习 ts 已是势在必行。

以下是我在工作中总结到的比较实用的 typescript 技巧。

本文链接: https://shanyue.tech/post/ts-...

01 keyof

keyofObject.keys 略有相似,只不过 keyofinterface 的键。

interface Point {
    x: number;
    y: number;
}

// type keys = "x" | "y"
type keys = keyof Point;

假设有一个 object 如下所示,我们需要使用 typescript 实现一个 get 函数来获取它的属性值

const data = {
  a: 3,
  hello: 'world'
}

function get(o: object, name: string) {
  return o[name]
}

我们刚开始可能会这么写,不过它有很多缺点

  1. 无法确认返回类型:这将损失 ts 最大的类型校验功能
  2. 无法对 key 做约束:可能会犯拼写错误的问题

这时可以使用 keyof 来加强 get 函数的类型功能,有兴趣的同学可以看看 _.get 的 type 标记以及实现

function get<T extends object, K extends keyof T>(o: T, name: K): T[K] {
  return o[name]
}

02 Partial & Pick

既然了解了 keyof,可以使用它对属性做一些扩展, 如实现 PartialPickPick 一般用在 _.pick

type Partial<T> = {
  [P in keyof T]?: T[P];
};

type Pick<T, K extends keyof T> = {
  [P in K]: T[P];
};

interface User {
  id: number;
  age: number;
  name: string;
};

// 相当于: type PartialUser = { id?: number; age?: number; name?: string; }
type PartialUser = Partial<User>

// 相当于: type PickUser = { id: number; age: number; }
type PickUser = Pick<User, "id" | "age">

03 Condition Type

类似于 js 中的 ?: 运算符,可以使用它扩展一些基本类型

T extends U ? X : Y

type isTrue<T> = T extends true ? true : false
// 相当于 type t = false
type t = isTrue<number>

// 相当于 type t = false
type t1 = isTrue<false>

04 never & Exclude & Omit

官方文档对 never 的描述如下

the never type represents the type of values that never occur.

结合 neverconditional type 可以推出很多有意思而且实用的类型,比如 Omit

type Exclude<T, U> = T extends U ? never : T;

// 相当于: type A = 'a'
type A = Exclude<'x' | 'a', 'x' | 'y' | 'z'>

结合 Exclude 可以推出 Omit 的写法

type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;

interface User {
  id: number;
  age: number;
  name: string;
};

// 相当于: type PickUser = { age: number; name: string; }
type OmitUser = Omit<User, "id">

05 typeof

顾名思义,typeof 代表取某个值的 type,可以从以下示例来展示他们的用法

const a: number = 3

// 相当于: const b: number = 4
const b: typeof a = 4

在一个典型的服务端项目中,我们经常需要把一些工具塞到 context 中,如config,logger,db models, utils 等,此时就使用到 typeof

import logger from './logger'
import utils from './utils'

interface Context extends KoaContect {
  logger: typeof logger,
  utils: typeof utils
}

app.use((ctx: Context) => {
  ctx.logger.info('hello, world')

  // 会报错,因为 logger.ts 中没有暴露此方法,可以最大限度的避免拼写错误
  ctx.loger.info('hello, world')
})

06 is

在此之前,先看一个 koa 的错误处理流程,以下是对 error 进行集中处理,并且标识 code 的过程

app.use(async (ctx, next) => {
  try {
    await next();
  } catch (err) {
    let code = 'BAD_REQUEST'
    if (err.isAxiosError) {
      code = `Axios-${err.code}`
    } else if (err instanceof Sequelize.BaseError) {

    }
    ctx.body = {
      code
    }
  }
})

err.code 处,会编译出错,提示 Property 'code' does not exist on type 'Error'.ts(2339)

此时可以使用 as AxiosError 或者 as any 来避免报错,不过强制类型转换也不够友好

if ((err as AxiosError).isAxiosError) {
  code = `Axios-${(err as AxiosError).code}`
}

此时可以使用 is 来判定值的类型

function isAxiosError (error: any): error is AxiosError {
  return error.isAxiosError
}

if (isAxiosError(err)) {
  code = `Axios-${err.code}`
}

GraphQL 的源码中,有很多诸如此类的用法,用以标识类型

export function isType(type: any): type is GraphQLType;

export function isScalarType(type: any): type is GraphQLScalarType;

export function isObjectType(type: any): type is GraphQLObjectType;

export function isInterfaceType(type: any): type is GraphQLInterfaceType;

07 interface & type

interfacetype 的区别是什么?可以参考以下 stackoverflow 的问题

https://stackoverflow.com/que...

一般来说,interfacetype 区别很小,比如以下两种写法差不多

interface A {
  a: number;
  b: number;
};

type B {
  a: number;
  b: number;
}

其中 interface 可以如下合并多个,而 type 只能使用 & 类进行连接。

interface A {
    a: number;
}

interface A {
    b: number;
}

const a: A = {
    a: 3,
    b: 4
}

08 Dictionary & Many

这几个语法糖是从 lodash 的 types 源码中学到的,平时工作中的使用频率还挺高。

interface Dictionary<T> {
  [index: string]: T;
};

interface NumericDictionary<T> {
  [index: number]: T;
};

const data:Dictionary<number> = {
  a: 3,
  b: 4
}

09 使用 const enum 维护常量表

相比使用字面量对象维护常量,const enum 可以提供更安全的类型检查

// 使用 object 维护常量
const enum TODO_STATUS {
    TODO = 'TODO',
    DONE = 'DONE',
    DOING = 'DOING'
}
// 使用 const enum 伟华常量
const enum TODO_STATUS {
    TODO = 'TODO',
    DONE = 'DONE',
    DOING = 'DOING'
}

function todos (status: TODO_STATUS): Todo[];

todos(TODO_STATUS.TODO)

10 VS Code Tips & Typescript Command

使用 VS Code 有时会出现,使用 tsc 编译时产生的问题与 vs code 提示的问题不一致

找到项目右下角的 Typescript 字样,右侧显示它的版本号,可以点击选择 Use Workspace Version,它表示与项目依赖的 typescript 版本一直。

或者编辑 .vs-code/settings.json

{
  "typescript.tsdk": "node_modules/typescript/lib"
}

11 Typescript Roadmap

最后一条也是最重要的一条,翻阅 Roadmap,了解 ts 的一些新的特性与 bug 修复情况。

Typescript Roadmap

参考

欢迎关注我的公众号山月行,在这里记录着我的技术成长,欢迎交流

欢迎关注公众号山月行,在这里记录我的技术成长,欢迎交流

查看原文

赞 47 收藏 36 评论 1

玩弄心里的鬼 发布了文章 · 2019-09-04

解密Vuex: 从源码开始

很多时候我们在开发一个Vue项目的时候,用一个Vue实例封装的EventBus来处理事件的传递从而达到组件间状态的共享。但是随着业务的复杂度提升,组件间共享的状态变得难以追溯和维护。因此我们需要将这些共享的状态通过一个全局的单例对象保存下来,在通过指定的方法去更新状态更新组件。

回顾基础知识

既然都说vuex是解决组件间数据通信的一种方式,那我们先来回顾下组件间通信的几种方法:

props传值

这种方法我们可以直接将父组件的值传递给子组件,并在子组件中调用。很明显,props是一种单向的数据绑定,并且子组件不能去修改props的值。在vue1.x中可以通过.async来实现双向绑定,但是这种双向的绑定很难去定位数据错误的来源,在vue2.3.0版本又加回了.async。

// 父组件
<Child name="hahaha" />

// 子组件
<div>{{name}}</div>
// ...
props: ['name']
// ...

$on $emit

如果子组件向父组件传递数据,我们可以通过$emit$on,在子组件注册事件,在父组件监听事件并作回调。

// 父组件
<Child @getName="getNameCb" />
// ...
getNameCb(name) {
  console.log('name');
}

// 子组件
someFunc() {
  this.$emit('getName', 'hahahah');
}

EventBus

前面两种方式很容易就解决了父子组件的通信问题,但是很难受的是,处理兄弟组件或者是祖孙组件的通信时你需要一层一层的去传递props,一层一层的去$emit。那么其实就可以使用EventBus了,EventBus实际上是一个Vue的实例,我们通过Vue实例的$emit$on来进行事件的发布订阅。但是问题也很明显,过多的使用EventBus也会造成数据源难以追溯的问题,并且不及时通过$off注销事件的化,也会发生很多奇妙的事情。

import EventBus from '...';

// 某一个组件
// ...
mounted() {
  EventBus.$on('someevent', (data) => {
    // ...
  })
}
// ...

// 某另一个组件
// ...
someFunc() {
  EventBus.$emit('someevent', 'hahahah');
}
// ...

Vuex

接下来就是我们要讲的Vuex了,以上这些问题Vuex都可以解决,Vuex也是Vue官方团队维护的Vue全家桶中的一员,作为Vue的亲儿子,Vuex毫无疑问是非常适合Vue项目的了。但是Vuex也不是完美的,毫无疑问在应用中加一层Store或多或少的都会增加学习和维护的成本,并且说白了一个小项目没几个组件,Vuex只会增加你的代码量,酌情使用吧。下面就进入到我们Vuex源码学习的正文了。

剖析原理

  • state:这里的state是一个单一的状态树;
  • mutations:在这里将触发同步事件,可以直接修改state;
  • actions:通过commit提交mutation,并且可以执行异步操作;
  • getters:这张图省略了getter,可以通过getter获取状态,同时也将被转化为vuex内部vue实例(_vm)的computed属性,从而实现响应式;

回顾一下Vuex的设计原理。我们把组件间共享的状态存储到Vuex的state中,并且组件会根据这个state的值去渲染。当需要更新state的时候,我们在组件中调用Vuex提供的dispatch方法去触发action,而在action中去通过commit方法去提交一个mutation,最后通过mutation去直接修改state,组件监听到state的更新最后更新组件。需要注意的有,mutaion不能执行异步操作,异步操作需要放到action中去完成;直接修改state的有且仅有mutation。(具体的使用方法笔者就不去啰嗦了,官方文档写的很详细,还有中文版,为啥不看...)

在笔者看来,Vuex的作用是用来解决组件间状态的共享,使项目更加利于维护,同样也是贯彻单向数据流这个理念。但其实从功能上讲,Vuex也像是一个前端的“数据库”,我们在使用Vuex时很像是后端同学对库的增删改查。

在Vue的项目中,我们也可以去使用Redux等来处理共享的状态,甚至是可以自己简单封装一个工具来处理状态,毕竟引入Vuex对开发同学来说也是有一定成本的。但是归根到底都是单向数据流的思想,一通则百通。

插个题外话,笔者在研究Vue ssr的时候不想去用Vuex做前后端状态共享,于是基于EventBus的思想对Vue实例进行了封装也同样实现了Vuex的功能,有兴趣的同学可以看下。戳这里

剖析源码

首先我们将挂载完Vuex实例的Vue实例打印出来看看挂载完增加了哪些东西。

这里不同于vue-router会在Vue的实例上增加很多的自定义属性,有的仅仅是一个$store属性,指向初始化的Vuex实例。

项目结构

拿到一个项目的源码我们要先去浏览他它的目录结构:

其中src是我们的源码部分:

  • helpers.js是Vuex的一些基础API,例如mapState、mapActions这些;
  • index.js和index.esm.js是我们的入口文件,不同的是index.esm.js采用了EcmaScript Module编写;
  • mixin.js是对mixin封装的一个函数;
  • module是Vuex中module相关逻辑的源码;
  • plugins中封装了我们常用的devtool和log相关的逻辑;
  • store.js是主要逻辑,这里封装了一个Store类;
  • util.js是对一些工具函数的封装;

应用入口

通常在构建包含Vuex的程序的时候会这么写:

import Vue from 'vue';
import Vuex from 'vuex';

Vue.use(Vuex);

const store = new Vuex({
  state: {...},
  mutations: {...},
  actions: {...},
});

new Vue({
  store,
  template,
}).$mount('#app')  

用过redux的小伙伴可以发现Vuex采用的是面向对象化的配置方式,不同于redux那种“偏函数式的初始化”,能更容易的让开发者理解。并且Vuex是以插件的形式安装在Vue实例上。

安装插件

在store.js中定义了一个符合Vue插件机制的导出函数install,并且封装了一个beforeCreate的mixin。

源码位置:/src/store.js /src/mixin.js

// store.js
// ...
// 绑定一个Vue实例;
// 不用将Vue打包进项目便可以使用Vue的提供的一些静态方法;
let Vue
// ...
// Vue 插件机制
export function install (_Vue) {
  if (Vue && _Vue === Vue) {
    if (process.env.NODE_ENV !== 'production') {
      console.error(
        '[vuex] already installed. Vue.use(Vuex) should be called only once.'
      )
    }
    return
  }
  Vue = _Vue
  // 封装mixin挂载$store
  applyMixin(Vue)
}
// mixin.js
export default function (Vue) {
  // 获取版本号
  const version = Number(Vue.version.split('.')[0])
  if (version >= 2) {
    Vue.mixin({ beforeCreate: vuexInit })
  } else {
    // 兼容低版本的Vue
    const _init = Vue.prototype._init
    Vue.prototype._init = function (options = {}) {
      options.init = options.init
        ? [vuexInit].concat(options.init)
        : vuexInit
      _init.call(this, options)
    }
  }
  // 封装mixin;  
  // 绑定$store实例;
  // 子组件的$store也始终指向根组件挂载的store实例;
  function vuexInit () {
    const options = this.$options
    // store injection
    if (options.store) {
      // store可能是一个工厂函数,vue ssr中避免状态交叉污染通常会用工厂函数封装store;
      this.$store = typeof options.store === 'function'
        ? options.store() 
        : options.store
    } else if (options.parent && options.parent.$store) {
      // 子组件从其父组件引用$store属性,嵌套设置
      this.$store = options.parent.$store
    }
  }
}

这里其实做的很简单就是在beforeCreate钩子中为Vue实例绑定了一个$store属性指向我们定义的Store实例上。此外也可以看到Vuex也采用了很常见的导出一个Vue实例,从而不将Vue打包进项目就能使用Vue提供的一些方法。

实例化Store

实例化Store类,我们先来看Store类的构造函数:

源码位置:/src/store.js

constructor (options = {}) {
    // 如果window上有Vue实例,直接安装插件;
    if (!Vue && typeof window !== 'undefined' && window.Vue) {
      install(window.Vue)
    }
    if (process.env.NODE_ENV !== 'production') {
      assert(Vue, `must call Vue.use(Vuex) before creating a store instance.`)
      assert(typeof Promise !== 'undefined', `vuex requires a Promise polyfill in this browser.`)
      assert(this instanceof Store, `store must be called with the new operator.`)
    }
    // 实例化store时传入的配置项;
    const {
      plugins = [],
      strict = false
    } = options

    // store internal state
    // 收集commit
    this._committing = false
    // 收集action
    this._actions = Object.create(null)
    // action订阅者
    this._actionSubscribers = []
    // 收集mutation
    this._mutations = Object.create(null)
    // 收集getter
    this._wrappedGetters = Object.create(null)
    // 收集module
    this._modules = new ModuleCollection(options)
    this._modulesNamespaceMap = Object.create(null)
    this._subscribers = []
    // 用以处理状态变化的Vue实例
    this._watcherVM = new Vue()
    // 将dispatch和commit调用的this指向Store实例;
    const store = this
    const { dispatch, commit } = this
    this.dispatch = function boundDispatch (type, payload) {
      return dispatch.call(store, type, payload)
    }
    this.commit = function boundCommit (type, payload, options) {
      return commit.call(store, type, payload, options)
    }
    // strict mode
    this.strict = strict
    // 获取state
    const state = this._modules.root.state
    // 主要作用就是生成namespace的map,挂载action、mutation、getter;
    installModule(this, state, [], this._modules.root)
    // 通过vm重设store,新建Vue对象使用Vue内部的响应式实现注册state以及computed
    resetStoreVM(this, state)
    // 使用插件
    plugins.forEach(plugin => plugin(this))
    if (Vue.config.devtools) {
      devtoolPlugin(this)
    }

可以看出整个构造函数中,主要就是声明一些基础的变量,然后最主要的就是执行了intsllModule函数来注册Module和resetStoreVM来使Store具有“响应式”。
至于ModuleCollection相关的代码我们暂且不去深究,知道他就是一个Module的收集器,并且提供了一些方法即可。

接下来看这两个主要的方法,首先是installModule,在这个方法中回去生成命名空间,然后挂载mutation、action、getter:

源码位置:/src/store.js

function installModule (store, rootState, path, module, hot) {
  const isRoot = !path.length
  const namespace = store._modules.getNamespace(path)
  // 生成name 和 Module 的 Map
  if (module.namespaced) {
    store._modulesNamespaceMap[namespace] = module
  }
  if (!isRoot && !hot) {
    const parentState = getNestedState(rootState, path.slice(0, -1))
    const moduleName = path[path.length - 1]
    // 为module注册响应式;
    store._withCommit(() => {
      Vue.set(parentState, moduleName, module.state)
    })
  }
  const local = module.context = makeLocalContext(store, namespace, path)
  // 挂载mutation
  module.forEachMutation((mutation, key) => {
    const namespacedType = namespace + key
    registerMutation(store, namespacedType, mutation, local)
  })
  // 挂载action
  module.forEachAction((action, key) => {
    const type = action.root ? key : namespace + key
    const handler = action.handler || action
    registerAction(store, type, handler, local)
  })
  // 挂载getter
  module.forEachGetter((getter, key) => {
    const namespacedType = namespace + key
    registerGetter(store, namespacedType, getter, local)
  })
  // 递归安装Module
  module.forEachChild((child, key) => {
    installModule(store, rootState, path.concat(key), child, hot)
  })
}

// ...
// 注册mutation
function registerMutation (store, type, handler, local) {
  // 在_mutations中找到对应type的mutation数组
  // 如果是第一次创建,就初始化为一个空数组
  const entry = store._mutations[type] || (store._mutations[type] = [])
  // push一个带有payload参数的包装过的函数
  entry.push(function wrappedMutationHandler (payload) {
    handler.call(store, local.state, payload)
  })
}
// 注册action
function registerAction (store, type, handler, local) {
  // 根据type找到对应的action; 
  const entry = store._actions[type] || (store._actions[type] = [])
  // push一个带有payload参数的包装过的函数
  entry.push(function wrappedActionHandler (payload, cb) {
    let res = handler.call(store, {
      dispatch: local.dispatch,
      commit: local.commit,
      getters: local.getters,
      state: local.state,
      rootGetters: store.getters,
      rootState: store.state
    }, payload, cb)
    // 如果 res 不是 promise 对象 ,将其转化为promise对象
    // 这是因为store.dispatch 方法里的 Promise.all()方法。
    if (!isPromise(res)) {
      res = Promise.resolve(res)
    }
    if (store._devtoolHook) {
      return res.catch(err => {
        store._devtoolHook.emit('vuex:error', err)
        throw err
      })
    } else {
      return res
    }
  })
}
// 注册getter
function registerGetter (store, type, rawGetter, local) {
  if (store._wrappedGetters[type]) {
    if (process.env.NODE_ENV !== 'production') {
      console.error(`[vuex] duplicate getter key: ${type}`)
    }
    return
  }
  // 将定义的getter全部存储到_wrappedGetters中;
  store._wrappedGetters[type] = function wrappedGetter (store) {
    return rawGetter(
      local.state, // local state
      local.getters, // local getters
      store.state, // root state
      store.getters // root getters
    )
  }
}

在Vuex的module中,我们是可以拆分很多个module出来的,每一个拆分出来的module又可以当作一个全新的module挂载在父级module上,因此这时候就需要一个path变量来区分层级关系了,我们可以根据这个path来去拿到每一次module下的state、mutation、action等。

接下来是resetStoreVM这个方法,在这个方法中,为store绑定了一个指向新的Vue实例的_vm属性,同时传入了state和computed,computed就是我们在store中设置的getter。

function resetStoreVM (store, state, hot) {
  const oldVm = store._vm

  // bind store public getters
  store.getters = {}
  const wrappedGetters = store._wrappedGetters
  const computed = {}
  // 为每一个getter设置get;
  forEachValue(wrappedGetters, (fn, key) => {
    // use computed to leverage its lazy-caching mechanism
    computed[key] = () => fn(store)
    Object.defineProperty(store.getters, key, {
      get: () => store._vm[key],
      enumerable: true // for local getters
    })
  })

  // use a Vue instance to store the state tree
  // suppress warnings just in case the user has added
  // some funky global mixins
  const silent = Vue.config.silent
  Vue.config.silent = true
  // 为store绑定Vue实例并注册state和computed
  store._vm = new Vue({
    data: {
      $$state: state
    },
    computed
  })
  Vue.config.silent = silent

  // enable strict mode for new vm
  if (store.strict) {
    enableStrictMode(store)
  }
  // 去除绑定旧vm
  if (oldVm) {
    if (hot) {
      // dispatch changes in all subscribed watchers
      // to force getter re-evaluation for hot reloading.
      store._withCommit(() => {
        oldVm._data.$$state = null
      })
    }
    Vue.nextTick(() => oldVm.$destroy())
  }
}

dispatch和commit

在Vuex中有两个重要的操作,一个是dispatch,一个是commit,我们通过dispatch去触发一个action,然后在action中我们通过提交commit去达到更新state的目的。下面就来看看这两部门的源码。

源码位置:/src/store.js

commit (_type, _payload, _options) {
    // check object-style commit
    // 检验类型;
    const {
      type,
      payload,
      options
    } = unifyObjectStyle(_type, _payload, _options)

    const mutation = { type, payload }
    // 找到type对应的mutation方法;
    const entry = this._mutations[type]
    if (!entry) {
      if (process.env.NODE_ENV !== 'production') {
        console.error(`[vuex] unknown mutation type: ${type}`)
      }
      return
    }
    // 执行mutation;
    this._withCommit(() => {
      entry.forEach(function commitIterator (handler) {
        handler(payload)
      })
    })
    // 通知订阅者
    this._subscribers.forEach(sub => sub(mutation, this.state))

    if (
      process.env.NODE_ENV !== 'production' &&
      options && options.silent
    ) {
      console.warn(
        `[vuex] mutation type: ${type}. Silent option has been removed. ` +
        'Use the filter functionality in the vue-devtools'
      )
    }
  }
  
  dispatch (_type, _payload) {
    // check object-style dispatch
    // 检验值;
    const {
      type,
      payload
    } = unifyObjectStyle(_type, _payload)

    const action = { type, payload }
    // 获取type对应的action;
    const entry = this._actions[type]
    if (!entry) {
      if (process.env.NODE_ENV !== 'production') {
        console.error(`[vuex] unknown action type: ${type}`)
      }
      return
    }
    // 通知action订阅者;
    this._actionSubscribers.forEach(sub => sub(action, this.state))
    // 返回action
    return entry.length > 1
      ? Promise.all(entry.map(handler => handler(payload)))
      : entry[0](payload)
  }

提供的静态方法

Vuex为我们提供了一些静态方法,都是通过调用绑定在Vue实例上的Store实例来操作我们的state、mutation、action和getter等。

源码位置:/src/helpers.js

//返回一个对象
//对象的属性名对应于传入的 states 的属性名或者数组元素
//执行这个函数的返回值根据 val 的不同而不同
export const mapState = normalizeNamespace((namespace, states) => {
  const res = {}
  normalizeMap(states).forEach(({ key, val }) => {
    res[key] = function mappedState () {
      let state = this.$store.state
      let getters = this.$store.getters
      if (namespace) {
        const module = getModuleByNamespace(this.$store, 'mapState', namespace)
        if (!module) {
          return
        }
        state = module.context.state
        getters = module.context.getters
      }
      return typeof val === 'function'
        ? val.call(this, state, getters)
        : state[val]
    }
    // mark vuex getter for devtools
    res[key].vuex = true
  })
  return res
})
// 返回一个对象
// 执行这个函数后将触发指定的 mutation 
export const mapMutations = normalizeNamespace((namespace, mutations) => {
  const res = {}
  normalizeMap(mutations).forEach(({ key, val }) => {
    res[key] = function mappedMutation (...args) {
      // Get the commit method from store
      let commit = this.$store.commit
      if (namespace) {
        const module = getModuleByNamespace(this.$store, 'mapMutations', namespace)
        if (!module) {
          return
        }
        commit = module.context.commit
      }
      return typeof val === 'function'
        ? val.apply(this, [commit].concat(args))
        : commit.apply(this.$store, [val].concat(args))
    }
  })
  return res
})

export const mapGetters = normalizeNamespace((namespace, getters) => {
  const res = {}
  normalizeMap(getters).forEach(({ key, val }) => {
    // thie namespace has been mutate by normalizeNamespace
    val = namespace + val
    res[key] = function mappedGetter () {
      if (namespace && !getModuleByNamespace(this.$store, 'mapGetters', namespace)) {
        return
      }
      if (process.env.NODE_ENV !== 'production' && !(val in this.$store.getters)) {
        console.error(`[vuex] unknown getter: ${val}`)
        return
      }
      return this.$store.getters[val]
    }
    // mark vuex getter for devtools
    res[key].vuex = true
  })
  return res
})

export const mapActions = normalizeNamespace((namespace, actions) => {
  const res = {}
  normalizeMap(actions).forEach(({ key, val }) => {
    res[key] = function mappedAction (...args) {
      // get dispatch function from store
      let dispatch = this.$store.dispatch
      if (namespace) {
        const module = getModuleByNamespace(this.$store, 'mapActions', namespace)
        if (!module) {
          return
        }
        dispatch = module.context.dispatch
      }
      return typeof val === 'function'
        ? val.apply(this, [dispatch].concat(args))
        : dispatch.apply(this.$store, [val].concat(args))
    }
  })
  return res
})
// 接受一个对象或者数组,最后都转化成一个数组形式,数组元素是包含key和value两个属性的对象
function normalizeMap (map) {
  return Array.isArray(map)
    ? map.map(key => ({ key, val: key }))
    : Object.keys(map).map(key => ({ key, val: map[key] }))
}
function normalizeNamespace (fn) {  return (namespace, map) => {
    if (typeof namespace !== 'string') {
      map = namespace
      namespace = ''
    } else if (namespace.charAt(namespace.length - 1) !== '/') {
      namespace += '/'
    }
    return fn(namespace, map)
  }
}

结语

笔者没有将全部的源码贴出来逐行分析,只是简单的分析了核心逻辑的源码。总的来说Vuex源码不多,写的很精练也很易懂,希望大家都能抽时间亲自看看源码学习学习。

查看原文

赞 15 收藏 10 评论 0

玩弄心里的鬼 发布了文章 · 2019-08-31

解密vue-router: 从源码开始

前几天笔者看到一个问题:你真的了解vue-router的吗?你知道vue-router的运行原理吗?抱着这样的问题,笔者开始了vue-router的源码探索之旅。本文并没有逐行去深究源码,而是跟着笔者画的流程图来简析每一步的运行流程。

剖析运行流程

笔者根据源码的结构和自己的理解事先画好了一张流程图,乍一看这张运行流程图可能会有点蒙圈,笔者接下来会现根据这张图分析下运行流程,然后再一步一步的剖析源码的核心部分。
运行流程
为了便于我们理解这张运行流程图,我们将挂载完vue-router的Vue实例打印出来看看都增加了什么东西:
1
2

  • $options下的router对象很好理解,这个就是我们在实例化Vue的时候挂载的那个vue-router实例;
  • _route是一个响应式的路由route对象,这个对象会存储我们路由信息,它是通过Vue提供的Vue.util.defineReactive来实现响应式的,下面的get和set便是对它进行的数据劫持;
  • _router存储的就是我们从$options中拿到的vue-router对象;
  • _routerRoot指向我们的Vue根节点;
  • _routerViewCache是我们对View的缓存;
  • $route$router是定义在Vue.prototype上的两个getter。前者指向_routerRoot下的_route,后者指向_routerRoot下的_router

接下来让我们顺顺这个“眼花缭乱的图”,以便于我们后面更好的理解之后的源码分析。

首先我们根据Vue的插件机制安装了vue-router,这里其实做的很简单,总结起来就是封装了一个mixin,定义了两个'原型',注册了两个组件。在这个mixin中,beforeCreate钩子被调用然后判断vue-router是否实例话了并初始化路由相关逻辑,前文提到的_routerRoot、_router、_route便是在此时被定义的。定义了两个“原型”是指在Vue.prototype上定一个两个getter,也就$route和$router。注册了两个组件是指在这里注册了我们后续会用到的RouterView和RouterLink这两个组件。

然后我们创建了一个VueRouter的实例,并将它挂载在Vue的实例上,这时候VueRouter的实例中的constructor初始化了各种钩子队列;初始化了matcher用于做我们的路由匹配逻辑并创建路由对象;初始化了history来执行过渡逻辑并执行钩子队列。

接下里mixin中beforeCreate做的另一件事就是执行了我们VueRouter实例的init()方法执行初始化,这一套流程和我们点击RouteLink或者函数式控制路由的流程类似,这里我就一起说了。在init方法中调用了history对象的transitionTo方法,然后去通过match获取当前路由匹配的数据并创建了一个新的路由对象route,接下来拿着这个route对象去执行confirmTransition方法去执行钩子队列中的事件,最后通过updateRoute更新存储当前路由数据的对象current,指向我们刚才创建的路由对象route。

最开始的时候我们说过_route被定义成了响应式的 那么一个路由更新之后,_route对象会接收到响应并通知RouteView去更新视图。

到此,流程就结束了,接下来我们将深入vue-router的源码去深度学习其原理。

剖析源码

说在前面

vue-router的源码都采用了flow作为类型检验,没有配置flow的话可能会满屏报错,本文不对flow做过多的介绍了。为了便于大家的理解,下面的源码部分我会将flow相关的语法去掉。顺便附上一些flow相关:

flow官方文档(需要科学上网):https://flow.org/
flow入门:https://zhuanlan.zhihu.com/p/...
flow配置:https://zhuanlan.zhihu.com/p/...

项目结构

在拿到一个项目的源码时候,我们首先要去看它的目录结构:
3
其中src是我们的项目源码部分,它包含如下结构:

  • componets是RouterLink和RouterView这两个组件;
  • create-matcher.js就是我们创建match的入口文件;
  • create-route-map.js用于创建path列表,path map,name map等;
  • history是创建hitory类的逻辑;
  • index.js就是我们的入口文件,其中创建了VueRouter这个类;
  • install.js是我们挂载vue-router插件的逻辑;
  • util定义了很多工具函数;

应用入口

通常我们去构建一个Vue应用程序的时候入口文件通常会这么写:

// app.js
import Vue from 'vue';
import VueRouter from 'vue-router';
import Main from '../components/main';

Vue.use(VueRouter);

const router = new VueRouter({
  routes: [{
    path: '/',
    component: Main,
  }],
});

// app.js
new Vue({
  router,
  template,
}).$mount('#app')

我们可以看到vue-router是以插件的形式安装的,并且vue-router的实例也会挂载在Vue的实例上面。

插件安装

此时我们将目光移入源码的入口文件,发现index.js中引入了install模块,并在VueRouter类上挂载了一个静态的install方法。而且还判断了环境中如果已经挂载了Vue则自动去使用这个插件。

源码位置:/src/index.js

import { install } from './install'
import { inBrowser } from './util/dom'
// ...
export default class VueRouter {}
// ...
// 挂载install;
VueRouter.install = install
// 判断如果window上挂载了Vue则自动使用插件;
if (inBrowser && window.Vue) {
  window.Vue.use(VueRouter)
}

接下来看install.js这个文件,这个文件导出了export方法以供Vue.use去安装:

源码位置:/src/install.js

import View from './components/view'
import Link from './components/link'

// export一个Vue的原因是可以不讲Vue打包进插件中而使用Vue一些方法;
// 只能在install之后才会存在这个Vue的实例;
export let _Vue

export function install (Vue) {
  // 如果插件已经安装就return
  if (install.installed && _Vue === Vue) return
  install.installed = true

  _Vue = Vue

  const isDef = v => v !== undefined

  const registerInstance = (vm, callVal) => {
    let i = vm.$options._parentVnode
    if (isDef(i) && isDef(i = i.data) && isDef(i = i.registerRouteInstance)) {
      i(vm, callVal)
    }
  }

  Vue.mixin({
    beforeCreate () {
      // this.$options.router为VueRouter实例;
      // 这里判断实例是否已经挂载;
      if (isDef(this.$options.router)) {
        // 将router的根组件指向Vue实例
        this._routerRoot = this
        this._router = this.$options.router
        // router初始化,调用VueRouter的init方法;
        this._router.init(this)
        // 使用Vue的defineReactive增加_route的响应式对象
        Vue.util.defineReactive(this, '_route', this._router.history.current)
      } else {
        // 将每一个组件的_routerRoot都指向根Vue实例;
        this._routerRoot = (this.$parent && this.$parent._routerRoot) || this
      }
      // 注册VueComponent 进行Observer处理;
      registerInstance(this, this)
    },
    destroyed () {
      // 注销VueComponent
      registerInstance(this)
    }
  })
  // 为$router和4route定义 << getter >> 分别指向_routerRoot的 _router 和 _route
  // _router 为VueRouter的实例;
  // _route 为一个存储了路由数据的对象;
  Object.defineProperty(Vue.prototype, '$router', {
    get () { return this._routerRoot._router }
  })

  Object.defineProperty(Vue.prototype, '$route', {
    get () { return this._routerRoot._route }
  })

  // 注册组件
  Vue.component('RouterView', View)
  Vue.component('RouterLink', Link)

  // Vue钩子合并策略
  const strats = Vue.config.optionMergeStrategies
  // use the same hook merging strategy for route hooks
  strats.beforeRouteEnter = strats.beforeRouteLeave = strats.beforeRouteUpdate = strats.created
}

这里需要注意的几点:

  • 导出一个Vue引用:这是为了不用将整个Vue打包进去就可以使用Vue提供的一些API,当然,这些的前提就是vue-router必须被安装挂载;
  • 在Vue.prototype上定义两个getter:Vue的组件都是Vue实例的一个扩展,他们都可以访问prototype上的方法和属性;
  • 定义响应式_route对象:有了这个响应式的路由对象,就可以在路由更新的时候及时的通知RouterView去更新组件了;

实例化VueRouter

接下来我们来看VueRouter类的实例化,在constructor中主要做的就两件事,创建matcher和创建history:

源码位置:/src/index.js

// ...
import { createMatcher } from './create-matcher'
import { supportsPushState } from './util/push-state'
import { HashHistory } from './history/hash'
import { HTML5History } from './history/html5'
import { AbstractHistory } from './history/abstract'
// ...
export default class VueRouter {
  constructor (options) {
    this.app = null
    this.apps = []
    // VueRouter 配置项;
    this.options = options
    // 三个钩子
    this.beforeHooks = []
    this.resolveHooks = []
    this.afterHooks = []
    // 创建路由匹配实例;传人我们定义的routes:包含path和component的对象;
    this.matcher = createMatcher(options.routes || [], this)
    // 判断模式
    let mode = options.mode || 'hash'
    // 判断浏览器是否支持history,如果不支持则回退到hash模式;
    this.fallback = mode === 'history' && !supportsPushState && options.fallback !== false
    if (this.fallback) {
      mode = 'hash'
    }
    // node运行环境 mode = 'abstract';
    if (!inBrowser) {
      mode = 'abstract'
    }
    this.mode = mode
    // 根据模式创建对应的history实例
    switch (mode) {
      case 'history':
        this.history = new HTML5History(this, options.base)
        break
      case 'hash':
        this.history = new HashHistory(this, options.base, this.fallback)
        break
      case 'abstract':
        this.history = new AbstractHistory(this, options.base)
        break
      default:
        if (process.env.NODE_ENV !== 'production') {
          assert(false, `invalid mode: ${mode}`)
        }
    }
  }
  // ...
}

创建matcher

顺着思路我们先看createMatcher这个函数:

源码位置:/src/create-matcher.js

import VueRouter from './index'
import { resolvePath } from './util/path'
import { assert, warn } from './util/warn'
import { createRoute } from './util/route'
import { fillParams } from './util/params'
import { createRouteMap } from './create-route-map'
import { normalizeLocation } from './util/location'

// routes为我们初始化VueRouter的路由配置;
// router就是我们的VueRouter实例;
export function createMatcher (routes, router) {
  // pathList是根据routes生成的path数组;
  // pathMap是根据path的名称生成的map;
  // 如果我们在路由配置上定义了name,那么就会有这么一个name的Map;
  const { pathList, pathMap, nameMap } = createRouteMap(routes)
  // 根据新的routes生成路由;
  function addRoutes (routes) {
    createRouteMap(routes, pathList, pathMap, nameMap)
  }
  // 路由匹配函数;
  function match (raw, currentRoute, redirectedFrom) {
    // 简单讲就是拿出我们path params query等等;
    const location = normalizeLocation(raw, currentRoute, false, router)
    const { name } = location

    if (name) {
      // 如果有name的话,就去name map中去找到这条路由记录;
      const record = nameMap[name]
      if (process.env.NODE_ENV !== 'production') {
        warn(record, `Route with name '${name}' does not exist`)
      }
      // 如果没有这条路由记录就去创建一条路由对象;
      if (!record) return _createRoute(null, location)
      const paramNames = record.regex.keys
        .filter(key => !key.optional)
        .map(key => key.name)

      if (typeof location.params !== 'object') {
        location.params = {}
      }

      if (currentRoute && typeof currentRoute.params === 'object') {
        for (const key in currentRoute.params) {
          if (!(key in location.params) && paramNames.indexOf(key) > -1) {
            location.params[key] = currentRoute.params[key]
          }
        }
      }

      if (record) {
        location.path = fillParams(record.path, location.params, `named route "${name}"`)
        return _createRoute(record, location, redirectedFrom)
      }
    } else if (location.path) {
      location.params = {}
      for (let i = 0; i < pathList.length; i++) {
        const path = pathList[i]
        const record = pathMap[path]
        // 根据当前路径进行路由匹配
        // 如果匹配就创建一条路由对象;
        if (matchRoute(record.regex, location.path, location.params)) {
          return _createRoute(record, location, redirectedFrom)
        }
      }
    }
    // no match
    return _createRoute(null, location)
  }
  
  // ...

  function _createRoute (record, location, redirectedFrom) {
    // 根据不同的条件去创建路由对象;
    if (record && record.redirect) {
      return redirect(record, redirectedFrom || location)
    }
    if (record && record.matchAs) {
      return alias(record, location, record.matchAs)
    }
    return createRoute(record, location, redirectedFrom, router)
  }

  return {
    match,
    addRoutes
  }
}

function matchRoute (regex, path, params) {
  const m = path.match(regex)

  if (!m) {
    return false
  } else if (!params) {
    return true
  }

  for (let i = 1, len = m.length; i < len; ++i) {
    const key = regex.keys[i - 1]
    const val = typeof m[i] === 'string' ? decodeURIComponent(m[i]) : m[i]
    if (key) {
      params[key.name] = val
    }
  }

  return true
}

function resolveRecordPath (path, record) {
  return resolvePath(path, record.parent ? record.parent.path : '/', true)
}

首先createMatcher会根据我们初始化VueRouter实例时候定义的routes配置,通过createRouteMap生成一份含有对应关系的map,具体逻辑下面我们会说到。然后返回一个包含match和addRoutes两个方法的对象match,就是我们实现路由匹配的详细逻辑,他会返回匹配的路由对象;addRoutes会就是添加路由的方法。

接下来我们顺着刚才的思路去看create-route-map.js

源码位置:/src/create-route-map.js

/* @flow */

import Regexp from 'path-to-regexp'
import { cleanPath } from './util/path'
import { assert, warn } from './util/warn'

export function createRouteMap (routes, oldPathList, oldPathMap, oldNameMap) {
  // the path list is used to control path matching priority
  const pathList = oldPathList || []
  // $flow-disable-line
  const pathMap = oldPathMap || Object.create(null)
  // $flow-disable-line
  const nameMap = oldNameMap || Object.create(null)
  // path列表
  // path的map映射
  // name的map映射
  // 为配置的路由项增加路由记录
  routes.forEach(route => {
    addRouteRecord(pathList, pathMap, nameMap, route)
  })

  // ensure wildcard routes are always at the end
  for (let i = 0, l = pathList.length; i < l; i++) {
    if (pathList[i] === '*') {
      pathList.push(pathList.splice(i, 1)[0])
      l--
      i--
    }
  }
  // 返回包含path数组,path map和name map的对象;
  return {
    pathList,
    pathMap,
    nameMap
  }
}

function addRouteRecord (pathList, pathMap, nameMap, route, parent, matchAs) {
  const { path, name } = route
  if (process.env.NODE_ENV !== 'production') {
    assert(path != null, `"path" is required in a route configuration.`)
    assert(
      typeof route.component !== 'string',
      `route config "component" for path: ${String(path || name)} cannot be a ` +
      `string id. Use an actual component instead.`
    )
  }

  // 定义 path 到 Reg 的选项;
  const pathToRegexpOptions: PathToRegexpOptions = route.pathToRegexpOptions || {}
  // 序列化path,'/'将会被替换成'';
  const normalizedPath = normalizePath(
    path,
    parent,
    pathToRegexpOptions.strict
  )

  // 正则匹配是否区分大小写;
  if (typeof route.caseSensitive === 'boolean') {
    pathToRegexpOptions.sensitive = route.caseSensitive
  }

  const record = {
    path: normalizedPath,
    regex: compileRouteRegex(normalizedPath, pathToRegexpOptions),
    components: route.components || { default: route.component },
    instances: {},
    name,
    parent,
    matchAs,
    redirect: route.redirect,
    beforeEnter: route.beforeEnter,
    meta: route.meta || {},
    props: route.props == null
      ? {}
      : route.components
        ? route.props
        : { default: route.props }
  }
  // 如果有嵌套的子路由,则递归添加路由记录;
  if (route.children) {
    // Warn if route is named, does not redirect and has a default child route.
    // If users navigate to this route by name, the default child will
    // not be rendered (GH Issue #629)
    if (process.env.NODE_ENV !== 'production') {
      if (route.name && !route.redirect && route.children.some(child => /^\/?$/.test(child.path))) {
        warn(
          false,
          `Named Route '${route.name}' has a default child route. ` +
          `When navigating to this named route (:to="{name: '${route.name}'"), ` +
          `the default child route will not be rendered. Remove the name from ` +
          `this route and use the name of the default child route for named ` +
          `links instead.`
        )
      }
    }
    route.children.forEach(child => {
      const childMatchAs = matchAs
        ? cleanPath(`${matchAs}/${child.path}`)
        : undefined
      addRouteRecord(pathList, pathMap, nameMap, child, record, childMatchAs)
    })
  }

  // 如果路由含有别名,则为其添加别名路由记录
  // 关于alias
  // https://router.vuejs.org/zh-cn/essentials/redirect-and-alias.html
  if (route.alias !== undefined) {
    const aliases = Array.isArray(route.alias)
      ? route.alias
      : [route.alias]

    aliases.forEach(alias => {
      const aliasRoute = {
        path: alias,
        children: route.children
      }
      addRouteRecord(
        pathList,
        pathMap,
        nameMap,
        aliasRoute,
        parent,
        record.path || '/' // matchAs
      )
    })
  }

  // 更新path map
  if (!pathMap[record.path]) {
    pathList.push(record.path)
    pathMap[record.path] = record
  }
  // 为定义了name的路由更新 name map
  if (name) {
    if (!nameMap[name]) {
      nameMap[name] = record
    } else if (process.env.NODE_ENV !== 'production' && !matchAs) {
      warn(
        false,
        `Duplicate named routes definition: ` +
        `{ name: "${name}", path: "${record.path}" }`
      )
    }
  }
}

function compileRouteRegex (path, pathToRegexpOptions) {
  const regex = Regexp(path, [], pathToRegexpOptions)
  if (process.env.NODE_ENV !== 'production') {
    const keys: any = Object.create(null)
    regex.keys.forEach(key => {
      warn(!keys[key.name], `Duplicate param keys in route with path: "${path}"`)
      keys[key.name] = true
    })
  }
  return regex
}

function normalizePath (path, parent, strict): string {
  if (!strict) path = path.replace(/\/$/, '')
  if (path[0] === '/') return path
  if (parent == null) return path
  return cleanPath(`${parent.path}/${path}`)
}

从上述代码可以看出,create-route-map.js的就是根据用户的routes配置的path、alias以及name来生成对应的路由记录。

创建history

matcher这一部分算是讲完了,接下来该说History的实例化了,从源码来说history文件夹下是有4个文件的,base作为基类,另外三个继承这个基类来分别处理vue-router的各种mode情况,这里我们主要看base的逻辑就可以了。

// install 到处的Vue,避免Vue打包进项目增加体积;
import { START, isSameRoute } from '../util/route'

export class History {
  constructor (router, base) {
    this.router = router
    this.base = normalizeBase(base)
    // start with a route object that stands for "nowhere"
    // 生成一个基础的route对象;
    this.current = START
    this.pending = null
    this.ready = false
    this.readyCbs = []
    this.readyErrorCbs = []
    this.errorCbs = []
  }
  // ...
}
// ...
function normalizeBase (base: ?string): string {
  if (!base) {
    if (inBrowser) {
      // respect <base> tag
      const baseEl = document.querySelector('base')
      base = (baseEl && baseEl.getAttribute('href')) || '/'
      // strip full URL origin
      base = base.replace(/^https?:\/\/[^\/]+/, '')
    } else {
      base = '/'
    }
  }
  // make sure there's the starting slash
  if (base.charAt(0) !== '/') {
    base = '/' + base
  }
  // remove trailing slash
  return base.replace(/\/$/, '')
}

基础的挂载和各种实例化都说完了之后,我们可以从init入手去看之后的流程了。
5
之前在讲install的时候知道了在mixin中的beforeCreate钩子里执行了init,现在我们移步到VueRouter的init方法。

源码位置:/src/index.js

// ...
init (app) {
    process.env.NODE_ENV !== 'production' && assert(
      install.installed,
      `not installed. Make sure to call \`Vue.use(VueRouter)\` ` +
      `before creating root instance.`
    )
    // 从install中的调用我们知道,这个app就是我们实例化的vVue实例;
    this.apps.push(app)

    // main app already initialized.
    if (this.app) {
      return
    }
    // 将VueRouter内的app指向我们亘Vue实例;
    this.app = app

    const history = this.history
    // 针对于 HTML5History 和 HashHistory 特殊处理,
    // 因为在这两种模式下才有可能存在进入时候的不是默认页,
    // 需要根据当前浏览器地址栏里的 path 或者 hash 来激活对应的路由
    if (history instanceof HTML5History) {
      history.transitionTo(history.getCurrentLocation())
    } else if (history instanceof HashHistory) {
      const setupHashListener = () => {
        history.setupListeners()
      }
      history.transitionTo(
        history.getCurrentLocation(),
        setupHashListener,
        setupHashListener
      )
    }
    //...
  }
// ...

可以看到初始化主要就是给app赋值,并且针对于HTML5History和HashHistory进行特殊的处理,因为在这两种模式下才有可能存在进入时候的不是默认页,需要根据当前浏览器地址栏里的path或者hash来激活对应的路由,此时就是通过调用transitionTo来达到目的;

接下来来看看这个具体的transitionTo:

源码位置:/src/history/base.js

transitionTo (location, onComplete, onAbort) {
    // localtion为我们当前页面的路由;
    // 调用VueRouter的match方法获取匹配的路由对象,创建下一个状态的路由对象;
    // this.current是我们保存的当前状态的路由对象;
    const route = this.router.match(location, this.current)
    this.confirmTransition(route, () => {
      // 更新当前的route对象;
      this.updateRoute(route)
      onComplete && onComplete(route)
      // 调用子类的方法更新url
      this.ensureURL()
      // fire ready cbs once
      // 调用成功后的ready的回调函数;
      if (!this.ready) {
        this.ready = true
        this.readyCbs.forEach(cb => { cb(route) })
      }
    }, err => {
      if (onAbort) {
        onAbort(err)
      }
      // 调用失败的err回调函数;
      if (err && !this.ready) {
        this.ready = true
        this.readyErrorCbs.forEach(cb => { cb(err) })
      }
    })
  }
  confirmTransition (route, onComplete, onAbort) {
    const current = this.current
    const abort = err => {
      if (isError(err)) {
        if (this.errorCbs.length) {
          this.errorCbs.forEach(cb => { cb(err) })
        } else {
          warn(false, 'uncaught error during route navigation:')
          console.error(err)
        }
      }
      onAbort && onAbort(err)
    }
    // 如果是同一个路由就不去跳转;
    if (
      isSameRoute(route, current) &&
      // in the case the route map has been dynamically appended to
      route.matched.length === current.matched.length
    ) {
      // 调用子类的方法更新url
      this.ensureURL()
      return abort()
    }
    // 交叉比对当前路由的路由记录和现在的这个路由的路由记录
    // 以便能准确得到父子路由更新的情况下可以确切的知道
    // 哪些组件需要更新 哪些不需要更新
    const {
      updated,
      deactivated,
      activated
    } = resolveQueue(this.current.matched, route.matched)
    // 注意,matched里头存储的是路由记录的数组;

    // // 整个切换周期的队列,待执行的各种钩子更新队列
    const queue: Array<?NavigationGuard> = [].concat(
      // in-component leave guards
      // 提取组件的 beforeRouteLeave 钩子
      extractLeaveGuards(deactivated),
      // global before hooks
      this.router.beforeHooks,
      // in-component update hooks
      // 提取组件的 beforeRouteUpdate 钩子
      extractUpdateHooks(updated),
      // in-config enter guards
      activated.map(m => m.beforeEnter),
      // async components
      // 异步处理组件
      resolveAsyncComponents(activated)
    )
    // 保存下一个状态的路由
    this.pending = route
    // 每一个队列执行的 iterator 函数
    const iterator = (hook: NavigationGuard, next) => {
      if (this.pending !== route) {
        return abort()
      }
      try {
        hook(route, current, (to: any) => {
          if (to === false || isError(to)) {
            // next(false) -> abort navigation, ensure current URL
            this.ensureURL(true)
            abort(to)
          } else if (
            typeof to === 'string' ||
            (typeof to === 'object' && (
              typeof to.path === 'string' ||
              typeof to.name === 'string'
            ))
          ) {
            // next('/') or next({ path: '/' }) -> redirect
            abort()
            if (typeof to === 'object' && to.replace) {
              this.replace(to)
            } else {
              this.push(to)
            }
          } else {
            // confirm transition and pass on the value
            next(to)
          }
        })
      } catch (e) {
        abort(e)
      }
    }
    // 执行各种钩子队列
    runQueue(queue, iterator, () => {
      const postEnterCbs = []
      const isValid = () => this.current === route
      // wait until async components are resolved before
      // extracting in-component enter guards
      // 等待异步组件 OK 时,执行组件内的钩子
      const enterGuards = extractEnterGuards(activated, postEnterCbs, isValid)
      const queue = enterGuards.concat(this.router.resolveHooks)
      // 在上次的队列执行完成后再执行组件内的钩子
      // 因为需要等异步组件以及是OK的情况下才能执行
      runQueue(queue, iterator, () => {
        if (this.pending !== route) {
          return abort()
        }
        // 路由过渡完成
        this.pending = null
        onComplete(route)
        if (this.router.app) {
          this.router.app.$nextTick(() => {
            postEnterCbs.forEach(cb => { cb() })
          })
        }
      })
    })
  }
  updateRoute (route) {
    const prev = this.current
    // 将current指向我们更新后的route对象;
    this.current = route
    this.cb && this.cb(route)
    this.router.afterHooks.forEach(hook => {
      hook && hook(route, prev)
    })
  }

逻辑看似复杂,实际上就是各种钩子函数的来回处理,但是这里要注意下,每一个路由route对象都会有一个matchd属性,这个属性包含一个路由记录,这个记录的生成在create-matcher.js中已经提到了。

等一下,我们好像漏了点东西,init后面还有一点没说:

源码位置:/src/index.js

// 设置路由改变时候的监听;
history.listen(route => {
    this.apps.forEach((app) => {
        app._route = route
    })
})

在这里设置了route改变之后的回调函数, 会在confirmTransition中的onComplete回调中调用, 并更新当前的_route的值,前面我们提到,_route是响应式的,那么当其更新的时候就会去通知组件重新render渲染。

两个组件

大体流程都看完了,接下来可以看看两个组件了,我们先看RouterView组件:
源码位置:/src/components/view.js

import { warn } from '../util/warn'

export default {
  name: 'RouterView',
  functional: true,
  props: {
    // 试图名称,默认是default
    name: {
      type: String,
      default: 'default'
    }
  },
  render (_, { props, children, parent, data }) {
    data.routerView = true
    // directly use parent context's createElement() function
    // so that components rendered by router-view can resolve named slots
    // 渲染函数
    const h = parent.$createElement
    const name = props.name
    // 拿到_route对象和缓存对象;
    const route = parent.$route
    const cache = parent._routerViewCache || (parent._routerViewCache = {})
    // determine current view depth, also check to see if the tree
    // has been toggled inactive but kept-alive.
    // 组件层级
    // 当 _routerRoot 指向 Vue 实例时就终止循环
    let depth = 0
    let inactive = false
    while (parent && parent._routerRoot !== parent) {
      if (parent.$vnode && parent.$vnode.data.routerView) {
        depth++
      }
      // 处理 keep-alive 组件
      if (parent._inactive) {
        inactive = true
      }
      parent = parent.$parent
    }
    data.routerViewDepth = depth

    // render previous view if the tree is inactive and kept-alive
    // 渲染缓存的 keep-alive 组件
    if (inactive) {
      return h(cache[name], data, children)
    }
    const matched = route.matched[depth]
    // render empty node if no matched route
    if (!matched) {
      cache[name] = null
      return h()
    }
    const component = cache[name] = matched.components[name]
    // attach instance registration hook
    // this will be called in the instance's injected lifecycle hooks
    // 添加注册钩子, 钩子会被注入到组件的生命周期钩子中
    // 在 src/install.js, 会在 beforeCreate 钩子中调用
    data.registerRouteInstance = (vm, val) => {
      // val could be undefined for unregistration
      const current = matched.instances[name]
      if (
        (val && current !== vm) ||
        (!val && current === vm)
      ) {
        matched.instances[name] = val
      }
    }
    // also register instance in prepatch hook
    // in case the same component instance is reused across different routes
    ;(data.hook || (data.hook = {})).prepatch = (_, vnode) => {
      matched.instances[name] = vnode.componentInstance
    }
    // resolve props
    let propsToPass = data.props = resolveProps(route, matched.props && matched.props[name])
    if (propsToPass) {
      // clone to prevent mutation
      propsToPass = data.props = extend({}, propsToPass)
      // pass non-declared props as attrs
      const attrs = data.attrs = data.attrs || {}
      for (const key in propsToPass) {
        if (!component.props || !(key in component.props)) {
          attrs[key] = propsToPass[key]
          delete propsToPass[key]
        }
      }
    }

    return h(component, data, children)
  }
}

function resolveProps (route, config) {
  switch (typeof config) {
    case 'undefined':
      return
    case 'object':
      return config
    case 'function':
      return config(route)
    case 'boolean':
      return config ? route.params : undefined
    default:
      if (process.env.NODE_ENV !== 'production') {
        warn(
          false,
          `props in "${route.path}" is a ${typeof config}, ` +
          `expecting an object, function or boolean.`
        )
      }
  }
}

function extend (to, from) {
  for (const key in from) {
    to[key] = from[key]
  }
  return to
}

然后是RouterLink组件:

源码位置:/src/components/link.js

/* @flow */

import { createRoute, isSameRoute, isIncludedRoute } from '../util/route'
import { _Vue } from '../install'

// work around weird flow bug
const toTypes: Array<Function> = [String, Object]
const eventTypes: Array<Function> = [String, Array]

export default {
  name: 'RouterLink',
  props: {
    to: {
      type: toTypes,
      required: true
    },
    tag: {
      type: String,
      default: 'a'
    },
    exact: Boolean,
    append: Boolean,
    replace: Boolean,
    activeClass: String,
    exactActiveClass: String,
    event: {
      type: eventTypes,
      default: 'click'
    }
  },
  render (h: Function) {
    // 获取挂载的VueRouter实例
    const router = this.$router
    // 获取当前的路由对象
    const current = this.$route
    // 获取当前匹配的路由信息
    const { location, route, href } = router.resolve(this.to, current, this.append)

    const classes = {}
    const globalActiveClass = router.options.linkActiveClass
    const globalExactActiveClass = router.options.linkExactActiveClass
    // Support global empty active class
    const activeClassFallback = globalActiveClass == null
      ? 'router-link-active'
      : globalActiveClass
    const exactActiveClassFallback = globalExactActiveClass == null
      ? 'router-link-exact-active'
      : globalExactActiveClass
    const activeClass = this.activeClass == null
      ? activeClassFallback
      : this.activeClass
    const exactActiveClass = this.exactActiveClass == null
      ? exactActiveClassFallback
      : this.exactActiveClass
    const compareTarget = location.path
      ? createRoute(null, location, null, router)
      : route

    classes[exactActiveClass] = isSameRoute(current, compareTarget)
    classes[activeClass] = this.exact
      ? classes[exactActiveClass]
      : isIncludedRoute(current, compareTarget)

    const handler = e => {
      if (guardEvent(e)) {
        if (this.replace) {
          router.replace(location)
        } else {
          router.push(location)
        }
      }
    }
    
    // 事件绑定
    const on = { click: guardEvent }
    if (Array.isArray(this.event)) {
      this.event.forEach(e => { on[e] = handler })
    } else {
      on[this.event] = handler
    }

    const data: any = {
      class: classes
    }

    if (this.tag === 'a') {
      data.on = on
      data.attrs = { href }
    } else {
      // find the first <a> child and apply listener and href
      // 找到第一个 <a> 给予这个元素事件绑定和href属性
      const a = findAnchor(this.$slots.default)
      if (a) {
        // in case the <a> is a static node
        a.isStatic = false
        const extend = _Vue.util.extend
        const aData = a.data = extend({}, a.data)
        aData.on = on
        const aAttrs = a.data.attrs = extend({}, a.data.attrs)
        aAttrs.href = href
      } else {
        // doesn't have <a> child, apply listener to self
        // 没有 <a> 的话就给当前元素自身绑定事件
        data.on = on
      }
    }

    return h(this.tag, data, this.$slots.default)
  }
}

function guardEvent (e) {
  // don't redirect with control keys
  if (e.metaKey || e.altKey || e.ctrlKey || e.shiftKey) return
  // don't redirect when preventDefault called
  if (e.defaultPrevented) return
  // don't redirect on right click
  if (e.button !== undefined && e.button !== 0) return
  // don't redirect if `target="_blank"`
  if (e.currentTarget && e.currentTarget.getAttribute) {
    const target = e.currentTarget.getAttribute('target')
    if (/\b_blank\b/i.test(target)) return
  }
  // this may be a Weex event which doesn't have this method
  if (e.preventDefault) {
    e.preventDefault()
  }
  return true
}

function findAnchor (children) {
  if (children) {
    let child
    for (let i = 0; i < children.length; i++) {
      child = children[i]
      if (child.tag === 'a') {
        return child
      }
      if (child.children && (child = findAnchor(child.children))) {
        return child
      }
    }
  }
}

结语

到这里,vue-router的源码剖析就告一段落了,虽然没有逐行去理解作者的思想,但也算是整体上捋顺了项目的运行原理,理解了原理也就更方便我们日常的需求开发了。最后,谢谢大家喜欢。

查看原文

赞 19 收藏 13 评论 0

玩弄心里的鬼 赞了文章 · 2019-07-16

精读《前端未来展望》

1. 引言

前端展望的文章越来越不好写了,随着前端发展的深入,需要拥有非常宽广的视野与格局才能看清前端的未来。

笔者根据自身经验,结合下面几篇文章发表一些总结与感悟:

读完这几篇文章可以发现,即便是最资深的前端从业者,每个人看前端未来也有不同的侧重点。这倒不是因为视野的局限,而是现在前端领域太多了,专精其中某几个领域就足够了,适量比全面更好。

同时前端底层也在逐渐封闭,虽然目睹了前端几十年变迁的开发者仍会对一些底层知识津津乐道,但通往底层的大门已经一扇扇逐渐关闭了,将更多的开发者挤到上层区域建设,所以仅学会近几年的前端知识依然能找到不错的工作。

然而上层建设是不封顶的,有人看到了山,有人看到了星球,不同业务环境,不同视野的人看到的东西都不同。

有意思是的国内和国外看到前端未来的视角也不同:国内看到的是追求更多的参与感、影响力,国外看到的是对新特性的持续跟进。

2. 精读

前端可以从多个角度理解,比如规范、框架、语言、社区、场景以及整条研发链路。

看待前端未来的角度随着视野不同也会有变化,比如 Serverless 是未来,务实的思考是:前端在 Serverless 研发链路中仅处于使用方,并不会因为用了 Serverless 而提升了技术含量。更高格局的思考是:怎么推动 Serverless 的建设,不把自己局限在前端。

所以当我们读到不同的人对前端理解的时候,有人站在一线前端研发的角度,有人站在全栈的角度,也有人站在业务负责人的角度。其实国内前端发展也到了这个阶段,老一辈的前端开拓者们已经进入不同的业务领域,承担着更多不同的职能分工,甚至是整个大业务线的领导者,这说明两点:

  1. 前辈已经用行动指出了前端突破天花板的各种方向。
  2. 同是前端未来展望,不同的文章侧重的格局不同,两个标题相同的文章内容可能大相径庭。

笔者顺着这些文章分析角度,发表一些自己的看法。

框架

在前端早期,也就是 1990 年浏览器诞生的时候,JS 没有良好的设计,浏览器也没有全面的实现,框架还没出来,浏览器之间就打起来了。

这也给前端发展定了一个基调:凭实力说话。

后面诞生的 Prototype、jquery 都是为了解决时代问题而诞生的,所以有种时代造就前端框架的感觉。

但到了最近几年,React、Angular、Vue 大有前端框架引领新时代的势头,前端要做的不再是填坑,而是模式创新。国内出现的小程序浪潮是个意料之外的现象,虽然群雄割据为开发者适配带来了一定成本,但本质上是中国在前端底层领域争取话语权的行为,而之所以各大公司不约而同的推出自己的小程序,则是商业、经济发展到了这个阶段的自然产物。

在原生开发领域,像 RN、Flutter 也是比较靠谱的移动端开发框架,RN 就长在 React 上,而 Flutter 的声明式 UI 也借鉴了前端框架的思路。每个框架都想往其他框架的领域渗透,所以标准总是很相近,各自的特色并没有宣传的那么明显,这个阶段只选用一种框架是明智的选择,未来这些框架之间会有更多使用场景争夺,但更多的是融合,推动新的开发方式提高生产力。

在数据驱动 UI 的方式上,具有代表性的是 React 的 Immutable 模式与 Vue 的 MVVM 观察者模式,前者模式虽然新颖,但是符合 JS 语言自然运行机制,Vue 的 MVVM 模式也相当好,特别是 Vue3.0 的 API 巧妙的解决了 React Hooks 无法解决的难题。如果 Vue 继续保持蓬勃的发展势头,未来前端 MVVM 模式甚至可能标准化,那么 Vue 是作为标准化的事实规范,还是和 JQuery 一样的命运,还需观察。

语言

JS 语言本身有满多缺陷的,但通过 babel 前端工程师可以提前享受到大部分新特性,这在很大程度上抵消了早期语言设计带来的问题。

横向对比来看,我们还可以把编程语言分为:前端语言、后端语言、能编译到 JS 的语言。

之所以有 “能编译到 JS 的语言” 这一类,是因为 JS Runtime 几乎是前端跨平台的通用标准,能编译到 JS 就代表了可跨平台,然而现在 “能编译到 JS 的语言” 除了紧贴 JS 做类型增强的 TS 外,其他并没有火起来,有工具链生态不匹配的原因,也有各大公司之间利益争夺的原因。

后端语言越来越贴场景化,比如 Go 主打轻量级高并发方案,Python 以其易用性占领了大部分大数据、人工智能的运算场景。

与此对应的是前端语言的同质化,前端语言绑定在前端框架的趋势越来越明显,比如 IOS 平台只能用 OC 和 Swift,安卓只能用 JAVA 和 Kotlin,Flutter 只支持 Dart,与其说这些语言更适合这些平台特性,不如说背后是谷歌、苹果、微软等巨头对平台生态掌控权的争夺。Web 与移动端要解决的问题是类似的:如何高效管理 UI 状态,现在大部分都采用数据驱动的思路,通过 JSX 或 Template 的方式描述出 UI DSL(更多可参考 前端开发编程语言的过去、现在和未来 UI DSL 一节)、以及性能提升:渲染和计算分离(这里又分为并发与调度两种实现思路,目的和效果是类似的)。

所以编程语言的未来也没什么悬念,前端领域如果有的选就用 JS,没得选只能依附所在平台绑定的语言,而前端语言最近正在完成一轮升级大迁徙:JS -> TS,JAVA -> Kotlin,OC -> Swift,前端语言的特性、易用性正在逐步趋同。需要说明的是,如果仅了解这些语言的语法,对编程能力是毫无帮助的,了解平台特性,解决业务问题,提供更好的交互体验才是前端应该不断追求的目标,随着前端、Native 开发者之间的流动,前端领域语言层面差异会会来越小,大家越关注上层,越倾向抹平语言差异,甚至可能 All in JS,这不是因为 JS 有多大野心,而是因为在解决的问题趋同、业务优先的大背景下,大家都需要减少语言不通带来的障碍,最好的办法就是统一语言,从人类语言的演变就可以发现,要解决的问题趋同(人类交流)、与国家绑定的小众语言一直都有生存空间、语法大同小异,但不同语言都有一定自己的特色(比如法语表意更精确)、跨语言学习成本高,所以当国际化协作频繁时,一定会催生一套官方语言(英语),而使用基数大的语言可能会发展为通用国际语言(中文)。

将编程语言的割裂、统一比作人类语言来看,就能理解现状,和未来发展趋势了。

可视化

前面也说过,前端的底层在逐渐封闭,而可视化就是前端的上层。

所以笔者很少提到工程化,原因就是未来前端开发者接触工程化的机会越来越少,工程化机制也越来越完善,前端会逐渐回归到自己的本质 - 人机交互,而交互的重要媒介就是图形,无论组件库还是智能化设计稿 To Code 都为了解放简单、模式化的交互工作,专业前端将更多聚集到图形化领域。

图形和数据是分不开的,所以图形化还要考虑性能问题与数据转换。

可视化是对性能要求最高的,因此像 web worker、GPU 加速都是常见处理手段,WASM 技术也会用到可视化中。具体到某个图表或大屏的性能优化,还会涉及数据抽样算法,分层渲染等,仅仅性能优化领域就有不少探索的空间。性能问题一般还伴随着数据量大,所以数据序列化方案也要一并考虑。

可视化图形学是非常学术的领域,从图形语法到交互语法,从一图一做的简单场景,到可视化分析场景的灵活拓展能力,再到探索式分析的图形语法完备性要求,可视化库想要一层层支持不同业务场景的需求,要有一个清晰的分层设计。

仅可视化的图形学领域,就足够将所有时间投入了,未来做可视化的前端会越来越专业,提供的工具库接口也越来越有一套最佳实践沉淀,对普通前端越来越友好。

BI 可视化分析就是前端深造的一个方向,跟随 BI 发展阶段,对前端的要求也在不断变化:工程化、组件化、搭建技术、渲染引擎、可视化、探索式、智能化,跟上产品对技术能力的要求,其实是相当有挑战性的。

编辑器

编辑器方向主要有 IDE(Web IDE)、富文本编辑器。

IDE 方向 国产做的比较好的是 HBuilder,国际上做的比较好的是 VSCode,由于微软还同时推出了 Web 版 MonacoEditor,让 Web IDE 开发的门槛大大降低。

作为使用者,现在和未来的主流可能都是微软系,毕竟微软在操作系统、IDE 方面人才储备和经验积累很多。但随着云服务的变迁,引导着开发方式升级,IDE 游戏规则可能迎来重大改变 - 云化。云化使得作为开发者拥有更多竞争的机会,因为云上 IDE 市场现在还是蓝海,现在很多创业公司和大公司内部都在走这个方向,这标志着中国计算机技术往更底层的技术发展,未来会有更多的话语权。

从发展阶段来说,前端也发展到了 Web IDE 这个时代。对大公司来说,内部有许许多多割裂的工程化孤岛,不仅消耗大量优秀的前端同学去维护,也造成内部物料体系、工程体系难以打通,阻碍了内部技术流通,而云 IDE 天生的中心化环境管理可以解决这个问题,同时还能带来抹平计算机环境差异、统一编译环境、源码不落盘、甚至实现自动的多人协作也成为了可能,而云 IDE 因为在云上,也不止于 IDE,还可以很方便的集成流程,将研发全链路打通,因此在阿里内部也成为了今年四大方向之一。

所以今年可以明显看到的是,前端又在逐步替代低水平重复的 UI 设计,从设计稿生成代码,到研发链路上云,这种顶层设计正在进一步收窄前端底层建设,所以未来会有更多专业前端涌入可视化领域。

富文本编辑器方向 是一个重要且小众的领域,老牌做的较好的是 UEditor 系列,现在论体验和周边功能完善度,做得最好的是语雀编辑器。开源也有很多优秀的实现,比如 Quill、DraftJS、Slate 等等,但现在富文本编辑器核心能力是功能完备性(是否支持视频、脑图、嵌入)、性能、服务化功能打通了多少(是否支持在线解析 pdf、ppt 等文件)、交互自然程度(拷贝内容的智能识别)等等。如果将眼光放到全球,那国外有大量优秀富文本编辑器案例,比如 Google Docs、Word Online、iCloud Pages 等等。

最好用的富文本编辑器往往不开源,因为投入的技术研发成本是巨大的,本身这项技术就是一个产品,卖点就是源码。

富文本编辑器功能强度可以分为三个级别:L0~L2:

  • L0:利用浏览器自带的输入框,主要指 contenteditable 实现。
  • L1:在 L0 的基础上通过 DOM API 自主实现增删改的功能,自定义能力非常强。
  • L2:从输入框、光标开始自主研发,完全不依赖浏览器特性,如果研发团队能力强,可以实现任何功能,典型产品比如 Google Docs。

无论国内外都鲜有进入 L2 强度的产品,除了超级大公司或者主打编辑器的创业公司。

所以编辑器方向中,无论 IDE 方向,还是富文本编辑器方向,都值得深入探索,其中 IDE 方向更偏工程化一些,考验体系化思维,编辑器方向更偏经验与技术,考验基本功和架构设计能力。

智能化

笔者认为智能化离前端这个工种是比较远的,智能化最终服务前后端,给前后端开发效率带来一个质的提升,而在此之前,作为前端从业者无非有两种选择:加入智能化开拓者队伍,或者准备好放弃可能被智能化替代的工作内容,积极投身于智能化解放开发者双手后,更具有挑战性的工作。这种挑战性的工作恰好包括了上面分析过的四个点:语言、框架、可视化、编辑器。

类比商业智能化,商业智能化包括网络协同和数据智能,也就是大量的网络协同产生海量数据,通过数据智能算法促进更好的算法模型、更高效的网络协同,形成一个反馈闭环。前端智能化也是类似,不管是自动切图、生成图片、页面,或者自动生成代码,都需要算法和前端工程师之间形成协同关系,并完成一个高效的反馈闭环,算法将是前端工程师手中的开发利器,且越规模化的使用功效越大。

另一种智能化方向是探索 BI 与可视化结合的智能化,通过功能完备的底层图表库,与后端通用 Cube 计算模型,形成一种探索式分析型 BI 产品,Tableau 就是典型的案例,在这个智能化场景中,需要对数据、产品、可视化全面理解的综合性人才,是前端职业生涯另一个突破点。

3. 总结

本文列举的五点显然不能代表前端的全貌,还遗漏了太多方面,比如工程化、组件化、Serverless 等,但 语言、框架、可视化、编辑器、智能化 这五个点是笔者认为前端,特别是国内前端值得持续发力,可以做深的点,成为任何一个领域的专家都足以突破前端工程师成长的天花板。

最后,前端是最贴近业务的技术之一,业务的未来决定了前端的未来,创造的业务价值决定了前端的价值,从现在开始锻炼自己的商业化思考能力与产品意识,看得懂业务,才能看到未来。

讨论地址是:精读《前端未来展望》 · Issue #178 · dt-fe/weekly

如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。

关注 前端精读微信公众号

<img width=200 data-original="https://img.alicdn.com/tfs/TB...;>

版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证
查看原文

赞 54 收藏 37 评论 2

玩弄心里的鬼 发布了文章 · 2019-07-15

解密Redux: 从源码开始

Redux是当今比较流行的状态管理库,它不依赖于任何的框架,并且配合着react-redux的使用,Redux在很多公司的React项目中起到了举足轻重的作用。接下来笔者就从源码中探寻Redux是如何实现的。

注意:本文不去过多的讲解Redux的使用方法,更多的使用方法和最佳实践请移步Redux官网

源码之前

基础概念

随着我们项目的复杂,项目中的状态就变得难以维护起来,这些状态在什么时候,处于什么原因,怎样变化的我们就很难去控制。因此我们考虑在项目中引入诸如Redux、Mobx这样的状态管理工具。

Redux其实很简单,可以简单理解为一个约束了特定规则并且包括了一些特殊概念的的发布订阅器。

在Redux中,我们用一个store来管理一个一个的state。当我们想要去修改一个state的时候,我们需要去发起一个action,这个action告诉Redux发生了哪个动作,但是action不能够去直接修改store里头的state,他需要借助reducer来描述这个行为,reducer接受state和action,来返回新的state。

三大原则

在Redux中有三大原则:

  • 单一数据源:所有的state都存储在一个对象中,并且这个对象只存在于唯一的store中;
  • state只读性:唯一改变state的方法就是去触发一个action,action用来描述发生了哪个行为;
  • 使用纯函数来执行修改:reducer描述了action如何去修改state,reducer必须是一个纯函数,同样的输入必须有同样的输出;

剖析源码

项目结构

项目结构

抛去一些项目的配置文件和其他,Redux的源码其实很少很简单:

  • index.js:入口文件,导出另外几个核心函数;
  • createStore.js:store相关的核心代码逻辑,本质是一个发布订阅器;
  • combineReducers.js:用来合并多个reducer到一个root reducer的相关逻辑;
  • bindActionCreators.js:用来自动dispatch的一个方法;
  • applyMiddleware.js:用来处理使用的中间件;
  • compose.js:导出一个通过从右到左组合参数函数获得的函数;
  • utils:两个个工具函数和一个系统注册的actionType;

从createStore来讲一个store的创建

首先我们先通过createStore函数的入参和返回值来简要理解它的功能:

export default function createStore(reducer, preloadedState, enhancer) {

  // ...

  return {
    dispatch,
    subscribe,
    getState,
    replaceReducer,
    [$$observable]: observable
  }
}

createStore接受三个参数:

  • reducer:用来描述action如何改变state的方法,它给定当前state和要处理的action,返回下一个state;
  • preloadedState:顾名思义就是初始化的state;
  • enhancer:可以直译为增强器,用它来增强store的第三方功能,Redux附带的唯一store增强器是applyMiddleware

createStore返回一个对象,对象中包含使用store的基本函数:

  • dispatch:用于action的分发;
  • subscribe:订阅器,他将会在每次action被dispatch的时候调用;
  • getState:获取store中的state值;
  • replaceReducer:替换reducer的相关逻辑;

接下来我们来看看createStore的核心逻辑,这里我省略了一些简单的警告和判断逻辑:

export default function createStore(reducer, preloadedState, enhancer) {
  // 判断是不是传入了过多的enhancer
  // ...

  // 如果不传入preloadedState只传入enhancer可以写成,const store = createStore(reducers, enhancer)
  // ...

  // 通过在增强器传入createStore来增强store的基本功能,其他传入的参数作为返回的高阶函数参数传入;
  if (typeof enhancer !== 'undefined') {
    if (typeof enhancer !== 'function') {
      throw new Error('Expected the enhancer to be a function.')
    }
    return enhancer(createStore)(reducer, preloadedState)
  }

  if (typeof reducer !== 'function') {
    throw new Error('Expected the reducer to be a function.')
  }

  // 闭包内的变量;
  // state作为内部变量不对外暴露,保持“只读”性,仅通过reducer去修改
  let currentReducer = reducer
  let currentState = preloadedState
  // 确保我们所操作的listener列表不是原始的listener列表,仅是他的一个副本;
  let currentListeners = []
  let nextListeners = currentListeners
  let isDispatching = false

  // 确保我们所操作的listener列表不是原始的listener列表,仅是他的一个副本;
  // 只有在dispatch的时候,才会去将currentListeners和nextListeners更新成一个;
  function ensureCanMutateNextListeners() {
    if (nextListeners === currentListeners) {
      nextListeners = currentListeners.slice()
    }
  }

  // 通过闭包返回了state,state仅可以通过此方法访问;
  function getState() {
    // 判断当前是否在dispatch过程中
    // ...

    return currentState
  }

  // Redux内部的发布订阅器
  function subscribe(listener) {
    // 判断listener的合法性
    // ...

    // 判断当前是否在dispatch过程中
    // ...

    let isSubscribed = true

    // 复制一份当前的listener副本
    // 操作的都是副本而不是源数据
    ensureCanMutateNextListeners()
    nextListeners.push(listener)

    return function unsubscribe() {
      if (!isSubscribed) {
        return
      }

      // 判断当前是否在dispatch过程中
      // ...

      isSubscribed = false

      ensureCanMutateNextListeners()

      // 根据当前listener的索引从listener数组中删除来实现取掉订阅;
      const index = nextListeners.indexOf(listener)
      nextListeners.splice(index, 1)
    }
  }

  function dispatch(action) {
    // 判断action是不是一个普通对象;
    // ...

    // 判断action的type是否合法
    // ...

    // 判断当前是否在dispatch过程中
    // ...

    try {
      isDispatching = true
      // 根据要触发的action, 通过reducer来更新当前的state;
      currentState = currentReducer(currentState, action)
    } finally {
      isDispatching = false
    }

    // 通知listener执行对应的操作;
    const listeners = (currentListeners = nextListeners)
    for (let i = 0; i < listeners.length; i++) {
      const listener = listeners[i]
      listener()
    }

    return action
  }

  // 替换reducer,修改state变化的逻辑
  function replaceReducer(nextReducer) {
    if (typeof nextReducer !== 'function') {
      throw new Error('Expected the nextReducer to be a function.')
    }

    currentReducer = nextReducer

    // 此操作对ActionTypes.INIT具有类似的效果。
    // 新旧rootReducer中存在的任何reducer都将收到先前的状态。
    // 这有效地使用来自旧状态树的任何相关数据填充新状态树。
    dispatch({ type: ActionTypes.REPLACE })
  }

  function observable() {
    const outerSubscribe = subscribe
    return {
      // 任何对象都可以被用作observer,observer对象应该有一个next方法
      subscribe(observer) {
        if (typeof observer !== 'object' || observer === null) {
          throw new TypeError('Expected the observer to be an object.')
        }

        function observeState() {
          if (observer.next) {
            observer.next(getState())
          }
        }

        observeState()
        const unsubscribe = outerSubscribe(observeState)
        // 返回一个带有unsubscribe方法的对象可以被用来在store中取消订阅
        return { unsubscribe }
      },

      [$$observable]() {
        return this
      }
    }
  }

  // 创建store时,将调度“INIT”操作,以便每个reducer返回其初始状态,以便state的初始化。
  dispatch({ type: ActionTypes.INIT })

  return {
    dispatch,
    subscribe,
    getState,
    replaceReducer,
    [$$observable]: observable
  }
}

从combineReducers谈store的唯一性

仅靠上面的createStore其实已经可以完成一个简单的状态管理了,但是随着业务体量的增大,state、action、reducer也会随之增大,我们不可能把所有的东西都塞到一个reducer里,最好是划分成不同的reducer来处理不同模块的业务。

但是也不能创建多个store维护各自的reducer,这就违背了Redux的单一store原则。为此,Redux提供了combineReducers让我们将按照业务模块划分的reducer合成一个rootReducer。

接下来我们看看combineReducers的源码,这里也是去掉了一些错误警告的代码和一些错误处理方法:

export default function combineReducers(reducers) {
  // 取出所有的reducer遍历合并到一个对象中
  const reducerKeys = Object.keys(reducers)
  const finalReducers = {}
  for (let i = 0; i < reducerKeys.length; i++) {
    const key = reducerKeys[i]

    // 判断未匹配的refucer
    // ...

    if (typeof reducers[key] === 'function') {
      finalReducers[key] = reducers[key]
    }
  }
  const finalReducerKeys = Object.keys(finalReducers)

   // 错误处理的一些逻辑
   // ...

  return function combination(state = {}, action) {

    // 错误处理的一些逻辑
    // ...

    let hasChanged = false
    const nextState = {}
    for (let i = 0; i < finalReducerKeys.length; i++) {
      const key = finalReducerKeys[i]
      // 对应的reducer
      const reducer = finalReducers[key]
      // 根据指定的reducer找到对应的state
      const previousStateForKey = state[key]
      // 执行reducer, 返回当前state
      const nextStateForKey = reducer(previousStateForKey, action)
      // nextStateForKey undefined的一些判断
      // ...

      // 整合每一个reducer对应的state
      nextState[key] = nextStateForKey
      // 判断新的state是不是同一引用, 以检验reducer是不是纯函数
      hasChanged = hasChanged || nextStateForKey !== previousStateForKey
    }
    return hasChanged ? nextState : state
  }
}

其实到这里可以简单的看出combineReducers就是把多个reducer拉伸展开到到一个对象里,同样也把每一个reducer里的state拉伸到一个对象里。

从bindActionCreators谈如何自动dispatch

现有的store每一次state的更新都需要手动的dispatch每一个action,而我们其实更需要的是自动的dispatch所有的action。这里就用到了bindActionCreators方法。

现在我们来看看bindActionCreators的源码

function bindActionCreator(actionCreator, dispatch) {
  return function() {
    return dispatch(actionCreator.apply(this, arguments))
  }
}

export default function bindActionCreators(actionCreators, dispatch) {
  // 返回绑定了this的actionCreator
  if (typeof actionCreators === 'function') {
    return bindActionCreator(actionCreators, dispatch)
  }

  // actionCreators类型判断的错误处理
  // ...

  // 为每一个actionCreator绑定this
  const boundActionCreators = {}
  for (const key in actionCreators) {
    const actionCreator = actionCreators[key]
    if (typeof actionCreator === 'function') {
      boundActionCreators[key] = bindActionCreator(actionCreator, dispatch)
    }
  }
  return boundActionCreators
}

其实我们在react项目中对这个方法是几乎无感知的,因为是在react-redux的connect中调用了这个方法来实现自动dispatch action的,不然需要手动去dispatch一个个action。

从compose谈函数组合

compose是Redux导出的一个方法,这方法就是利用了函数式的思想对函数进行组合:

// 通过从右到左组合参数函数获得的函数。例如,compose(f, g, h)与do(...args)=> f(g(h(... args)))相同。
export default function compose(...funcs) {
  if (funcs.length === 0) {
    return arg => arg
  }

  if (funcs.length === 1) {
    return funcs[0]
  }

  return funcs.reduce((a, b) => (...args) => a(b(...args)))
}

从applyMiddleware谈如何自定义dispatch

我们的action会出现同步的场景,当然也会出现异步的场景,在这两种场景下dispacth的执行时机是不同的,在Redux中,可以使用middleware来对dispatch进行改造,下面我们来看看applyMiddleware的实现:

import compose from './compose'

export default function applyMiddleware(...middlewares) {
  return createStore => (...args) => {
    const store = createStore(...args)
    let dispatch = () => {
      throw new Error(
        'Dispatching while constructing your middleware is not allowed. ' +
          'Other middleware would not be applied to this dispatch.'
      )
    }

    const middlewareAPI = {
      getState: store.getState,
      dispatch: (...args) => dispatch(...args)
    }
    const chain = middlewares.map(middleware => middleware(middlewareAPI))
    // 通过从右到左组合参数函数获得的函数。例如,compose(f, g, h)与do(...args)=> f(g(h(... args)))相同。
    // 对dispatch改造
    dispatch = compose(...chain)(store.dispatch)

    return {
      ...store,
      dispatch
    }
  }
}

结语

到此,Redux源码的部分就分析完了,但是在具体和React结合的时候还需要用到react-redux,下一篇文章,我将深入到react-redux的源码学习,来探索在react中,我们如何去使用Redux。

查看原文

赞 8 收藏 6 评论 0

玩弄心里的鬼 发布了文章 · 2019-05-20

解密JavaScript执行上下文

执行上下文栈

首先我们先了解一下什么是执行上下文栈(Execution context stack)。

堆,栈和队列
上面这张图来自于mdn,分别展示了栈、堆和队列,其中栈就是我们所说的执行上下文栈;堆是用于存储对象这种复杂类型,我们复制对象的地址引用就是这个堆内存的地址;队列就是异步队列,用于event loop的执行。

JS代码在引擎中是以“一段一段”的方式来分析执行的,而并非一行一行来分析执行。而这“一段一段”的可执行代码无非为三种:Global codeFunction CodeEval code。这些可执行代码在执行的时候又会创建一个一个的执行上下文(Execution context)。例如,当执行到一个函数的时候,JS引擎会做一些“准备工作”,而这个“准备工作”,我们称其为执行上下文

那么随着我们的执行上下文数量的增加,JS引擎又如何去管理这些执行上下文呢?这时便有了执行上下文栈。

这里我用一段贯穿全文的例子来讲解执行上下文栈的执行过程:

var scope = 'global scope';

function checkscope(s) {
  var scope = 'local scope';

  function f() {
    return scope;
  }
  return f();
}
checkscope('scope');

当JS引擎去解析代码的时候,最先碰到的就是Global code,所以一开始初始化的时候便会将全局上下文推入执行上下文栈,并且只有在整个应用程序执行完毕的时候,全局上下文才会推出执行上下文栈。

这里我们用ECS来模拟执行上下文栈,用globalContext来表示全局上下文:

ESC = [
  globalContext, // 一开始只有全局上下文
]

然后当代码执行checkscope函数的时候,会创建checkscope函数的执行上下文,并将其压入执行上下文栈:

ESC = [
  checkscopeContext, // checkscopeContext入栈
  globalContext,
]

接着代码执行到return f()的时候,f函数的执行上下文被创建:

ESC = [
  fContext, // fContext入栈
  checkscopeContext,
  globalContext,
]

f函数执行完毕后,f函数的执行上下文出栈,随后checkscope函数执行完毕,checkscope函数的执行上下文出栈:

// fContext出栈
ESC = [
  // fContext出栈
  checkscopeContext,
  globalContext,
]

// checkscopeContext出栈
ESC = [
  // checkscopeContext出栈
  globalContext,
]

变量对象

每一个执行上下文都有三个重要的属性:

  • 变量对象
  • 作用域链
  • this

这一节我们先来说一下变量对象(Variable object,这里简称VO)。

变量对象是与执行上下文相关的数据作用域,存储了在上下文中定义的变量和函数声明。并且不同的执行上下文也有着不同的变量对象,这里分为全局上下文中的变量对象和函数执行上下文中的变量对象。

全局上下文中的变量对象

全局上下文中的变量对象其实就是全局对象。我们可以通过this来访问全局对象,并且在浏览器环境中,this === window;在node环境中,this === global

this === window

this === global

函数上下文中的变量对象

在函数上下文中的变量对象,我们用活动对象来表示(activation object,这里简称AO),为什么称其为活动对象呢,因为只有到当进入一个执行上下文中,这个执行上下文的变量对象才会被激活,并且只有被激活的变量对象,其属性才能被访问。

在函数执行之前,会为当前函数创建执行上下文,并且在此时,会创建变量对象:

  • 根据函数arguments属性初始化arguments对象;
  • 根据函数声明生成对应的属性,其值为一个指向内存中函数的引用指针。如果函数名称已存在,则覆盖;
  • 根据变量声明生成对应的属性,此时初始值为undefined。如果变量名已声明,则忽略该变量声明;

还是以刚才的代码为例:

var scope = 'global scope';

function checkscope(s) {
  var scope = 'local scope';

  function f() {
    return scope;
  }
  return f();
}
checkscope('scope');

在执行checkscope函数之前,会为其创建执行上下文,并初始化变量对象,此时的变量对象为:

VO = {
  arguments: {
    0: 'scope',
    length: 1,
  },
  s: 'scope', // 传入的参数
  f: pointer to function f(),
  scope: undefined, // 此时声明的变量为undefined
}

随着checkscope函数的执行,变量对象被激活,变相对象内的属性随着代码的执行而改变:

VO = {
  arguments: {
    0: 'scope',
    length: 1,
  },
  s: 'scope', // 传入的参数
  f: pointer to function f(),
  scope: 'local scope', // 变量赋值
}

其实也可以用另一个概念“函数提升”和“变量提升”来解释:

function checkscope(s) {
  function f() { // 函数提升
    return scope;
  }
  var scope; // 变量声明提升

  scope = 'local scope' // 变量对象的激活也相当于此时的变量赋值

  return f();
}

作用域链

每一个执行上下文都有三个重要的属性:

  • 变量对象
  • 作用域链
  • this

这一节我们说一下作用域链。

什么是作用域链

当查找变量的时候,会先从当前上下文的变量对象中查找,如果没有找到,就会从父级执行上下文的变量对象中查找,一直找到全局上下文的变量对象。这样由多个执行上下文的变量对象构成的链表就叫做作用域链。

下面还是用我们的例子来讲解作用域链:

var scope = 'global scope';

function checkscope(s) {
  var scope = 'local scope';

  function f() {
    return scope;
  }
  return f();
}
checkscope('scope');

首先在checkscope函数声明的时候,内部会绑定一个[[scope]]的内部属性:

checkscope.[[scope]] = [
  globalContext.VO
];

接着在checkscope函数执行之前,创建执行上下文checkscopeContext,并推入执行上下文栈:

  • 复制函数的[[scope]]属性初始化作用域链;
  • 创建变量对象;
  • 将变量对象压入作用域链的最顶端;
// -> 初始化作用域链;
checkscopeContext = {
  scope: checkscope.[[scope]],
}

// -> 创建变量对象
checkscopeContext = {
  scope: checkscope.[[scope]],
  VO = {
    arguments: {
      0: 'scope',
      length: 1,
    },
    s: 'scope', // 传入的参数
    f: pointer to function f(),
    scope: undefined, // 此时声明的变量为undefined
  },
}

// -> 将变量对象压入作用域链的最顶端
checkscopeContext = {
  scope: [VO, checkscope.[[scope]]],
  VO = {
    arguments: {
      0: 'scope',
      length: 1,
    },
    s: 'scope', // 传入的参数
    f: pointer to function f(),
    scope: undefined, // 此时声明的变量为undefined
  },
}

接着,随着函数的执行,修改变量对象:

checkscopeContext = {
  scope: [VO, checkscope.[[scope]]],
  VO = {
    arguments: {
      0: 'scope',
      length: 1,
    },
    s: 'scope', // 传入的参数
    f: pointer to function f(),
    scope: 'local scope', // 变量赋值
  }
}

与此同时遇到f函数声明,f函数绑定[[scope]]属性:

checkscope.[[scope]] = [
  checkscopeContext.VO, // f函数的作用域还包括checkscope的变量对象
  globalContext.VO
];

之后f函数的步骤同checkscope函数。

再来一个经典的例子:

var data = [];

for (var i = 0; i < 6; i++) {
  data[i] = function () {
    console.log(i);
  };
}

data[0]();
// ...

很简单,不管访问data几,最终console打印出来的都是6,因为在ES6之前,JS都没有块级作用域的概念,for循环内的代码都在全局作用域下。

在data函数执行之前,此时全局上下文的变量对象为:

globalContext.VO = {
  data: [pointer to function ()],
  i: 6, // 注意:此时的i值为6
}

每一个data匿名函数的执行上下文链大致都如下:

data[n]Context = {
  scope: [VO, globalContext.VO],
  VO: {
    arguments: {
      length: 0,
    }
  }
}

那么在函数执行的时候,会先去自己匿名函数的变量对象上找i的值,发现没有后会沿着作用域链查找,找到了全局执行上下文的变量对象,而此时全局执行上下文的变量对象中的i为6,所以每一次都打印的是6了。

词法作用域 & 动态作用域

JavaScript这门语言是基于词法作用域来创建作用域的,也就是说一个函数的作用域在函数声明的时候就已经确定了,而不是函数执行的时候。

改一下之前的例子:

var scope = 'global scope';

function f() {
  console.log(scope)
}

function checkscope() {
  var scope = 'local scope';

  f();
}
checkscope();

因为JavaScript是基于词法作用域创建作用域的,所以打印的结果是global scope而不是local scope。我们结合上面的作用域链来分析一下:

首先遇到了f函数的声明,此时为其绑定[[scope]]属性:

// 这里就是我们所说的“一个函数的作用域在函数声明的时候就已经确定了”
f.[[scope]] = [
  globalContext.VO, // 此时的全局上下文的变量对象中保存着scope = 'global scope';
];

然后我们直接跳过checkscope的执行上下文的创建和执行的过程,直接来到f函数的执行上。此时在函数执行之前初始化f函数的执行上下文:

// 这里就是为什么会打印global scope
fContext = {
  scope: [VO, globalContext.VO], // 复制f.[[scope]],f.[[scope]]只有全局执行上下文的变量对象
  VO = {
    arguments: {
      length: 0,
    },
  },
}

然后到了f函数执行的过程,console.log(scope),会沿着f函数的作用域链查找scope变量,先是去自己执行上下文的变量对象中查找,没有找到,然后去global执行上下文的变量对象上查找,此时scope的值为global scope

this

在这里this绑定也可以分为全局执行上下文和函数执行上下文:

  • 在全局执行上下文中,this的指向全局对象。(在浏览器中,this引用 Window 对象)。
  • 在函数执行上下文中,this 的值取决于该函数是如何被调用的。如果它被一个引用对象调用,那么this会被设置成那个对象,否则this的值被设置为全局对象或者undefined(在严格模式下)

总结起来就是,谁调用了,this就指向谁。

执行上下文

这里,根据之前的例子来完整的走一遍执行上下文的流程:

var scope = 'global scope';

function checkscope(s) {
  var scope = 'local scope';

  function f() {
    return scope;
  }
  return f();
}
checkscope('scope');

首先,执行全局代码,创建全局执行上下文,并且全局执行上下文进入执行上下文栈:

globalContext = {
  scope: [globalContext.VO],
  VO: global,
  this: globalContext.VO
}

ESC = [
  globalContext,
]

然后随着代码的执行,走到了checkscope函数声明的阶段,此时绑定[[scope]]属性:

checkscope.[[scope]] = [
  globalContext.VO,
]

在checkscope函数执行之前,创建checkscope函数的执行上下文,并且checkscope执行上下文入栈:

// 创建执行上下文
checkscopeContext = {
  scope: [VO, globalContext.VO], // 复制[[scope]]属性,然后VO推入作用域链顶端
  VO = {
    arguments: {
      0: 'scope',
      length: 1,
    },
    s: 'scope', // 传入的参数
    f: pointer to function f(),
    scope: undefined,
  },
  this: globalContext.VO,
}

// 进入执行上下文栈
ESC = [
  checkscopeContext,
  globalContext,
]

checkscope函数执行,更新变量对象:

// 创建执行上下文
checkscopeContext = {
  scope: [VO, globalContext.VO], // 复制[[scope]]属性,然后VO推入作用域链顶端
  VO = {
    arguments: {
      0: 'scope',
      length: 1,
    },
    s: 'scope', // 传入的参数
    f: pointer to function f(),
    scope: 'local scope', // 更新变量
  },
  this: globalContext.VO,
}

f函数声明,绑定[[scope]]属性:

f.[[scope]] = [
  checkscopeContext.VO,
  globalContext.VO,
]

f函数执行,创建执行上下文,推入执行上下文栈:

// 创建执行上下文
fContext = {
  scope: [VO, checkscopeContext.VO, globalContext.VO], // 复制[[scope]]属性,然后VO推入作用域链顶端
  VO = {
    arguments: {
      length: 0,
    },
  },
  this: globalContext.VO,
}

// 入栈
ESC = [
  fContext,
  checkscopeContext,
  globalContext,
]

f函数执行完成,f函数执行上下文出栈,checkscope函数执行完成,checkscope函数出栈:

ESC = [
  // fContext出栈
  checkscopeContext,
  globalContext,
]

ESC = [
  // checkscopeContext出栈,
  globalContext,
]

到此,一个整体的执行上下文的流程就分析完了。

查看原文

赞 20 收藏 15 评论 2

认证与成就

  • 获得 193 次点赞
  • 获得 3 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 3 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

  • xcui

    XCUI 是基于Vue2.0的桌面端组件库。

注册于 2016-01-22
个人主页被 2k 人浏览