款冬

款冬 查看完整档案

杭州编辑  |  填写毕业院校  |  填写所在公司/组织填写个人主网站
编辑

前端小小弄潮儿

个人动态

款冬 关注了标签 · 2月7日

关注 110

款冬 发布了文章 · 2月6日

qiankun微前端实践

前言

在介绍具体的接入方案和文档之前,先简单介绍一下,什么是微前端,以及为什么我们的前端工程要做微前端化的改造?

什么是微前端?

微前端,用一句话来解释就是:将多个独立的前端应用聚合在一起组成一个全新的应用,各个应用直接彼此独立,互不干扰。也就是说,一个微前端应用给用户的感观就是一个完整的应用,但是在技术角度上是由一个个独立的应用组合通过某种方式组合而成的。再举个大家都知道都例子,ifream就是微前端应用都一种,现在仍然活跃在很多控制台系统中,但是因为其自身存在的一些缺陷,现在业内有了更好的解决方案,这就是后面要重点介绍的内容。

微前端的价值?

为什么要做微前端?这也是要看业务场景的,微前端的价值我认为主要体现在两个方面:1.解决历史大工程的迭代问题2.控制台的应用集成,为用户提供统一入口。这两方面可以分开进行阐述:

历史大工程的迭代问题

这是很常见的业务场景,一般有2种非常典型的痛点问题:

1.一个后管平台迭代了很多版本之后,整个工程的体积很大,本地编译的速度很慢,而且经常是多人分别开发不同模块,不管哪个模块的改动,也需要整个工程重新进行编译、打包、部署。

2.原先的工程是用旧的技术栈(如jquery),现在要集成新的功能模块,想要新的技术栈(如react、vue)进行迭代。或是团队同学技术栈不一样,希望各自负责的模块开发可以用不同的技术栈进行开发,最终都能集成到原应用中。

控制台的应用集成

这个也很好理解,希望将各个应用的入口进行收拢,进行统一管理。解决零散的页面入口问题,提升用户体验。

正文

言归正传,下面结合实际的工程例子,开始详细介绍本次微前端的技术方案和接入文档:

技术选型

在调研了业内多个微前端的框架后,最终定下来基于 "乾坤" 框架(文档点这里)进行微前端的解决方案改造,选用 "乾坤"的原因有2点,1.框架的可靠性和口碑较好,githup(详情点这里)上有8.2k的star。2. 接入简单,不会对原有工程造成很大"破坏"。关于框架的地址和文档自行移步,不再重复介绍。

技术方案

应用分层

因为面向的都是后管系统,后管系统有一些通用的特点:需要登录鉴权,有左侧的菜单栏和上方的个人信息导航栏,中间部分是具体的页面内容。基于这个特点,可以把系统分成主应用(登录+菜单+导航)和子应用(具体的内容页面)两个部分。

截屏2021-01-18 下午7.58.37.png

主应用定位

主应用的定位是一个架子,主要是集成登录、菜单、导航,以及提供全局数据和公共方法,具体的功能模块尽量放到子应用中

子应用定位

子应用的设计需要额外考虑独立性,如果是在微前端的环境中,子应用是作为整体应用的一部分的,因此会与主应用有着紧密的联系。但是子应用本身也应该做到相对的独立,脱离了主应用也能单独运行,因此在方案的设计中,要考虑子应用既能集成在主应用中以微前端的架构运行,也要保证子应用能够脱离主应用单独运行。

配置流程

乾坤应用的配置流程其实很简单,只需要分别在主应用和子应用进行各自需要的配置即可。主应用中需要安装好qiankun框架,配置需要挂载的子应用的挂载规则,开启qiankun应用。而在子应用中需要在入口文件中暴露出 bootstrap、mount、unmount 三个生命周期钩子,以供主应用在适当的时机调用,接收主应用传递的props。此外,为了让主应用能正确识别微应用暴露出来的一些信息,需要在对应的打包工具中增加少量的配置信息。

截屏2021-01-24 下午3.47.55.png

主应用配置

应用的注册和挂载指的是在主应用中注册微应用,设置好在微应用在主应用中挂载的页面元素,以及触发子应用加载的页面url。用官网的例子来说明:

// 主应用的入口文件
import { registerMicroApps, start } from 'qiankun';
registerMicroApps([
  {
    name: 'react app', // 应用名称,必须唯一
    entry: '//localhost:7100', // 子应用的地址
    container: '#yourContainer', // 页面中挂载子应用的元素节点
    activeRule: '/yourActiveRule', // 触发子应用加载的页面ur
  },
  {
    name: 'vue app',
    entry: { scripts: ['//localhost:7100/main.js'] },
    container: '#yourContainer2',
    activeRule: '/yourActiveRule2',
  },
]);
start(); // 运行乾坤

一般上述的方式已经能满足大多数常见的应用场景来,如果微应用不是直接跟路由关联或是有需要手动触发子应用加载的场景,比如子应用嵌套的场景,需要在一个子应用中手动去加载另一个子应用。这时候乾坤提供了一个 loadMicroApp 的方法进行子应用的手动加载:

import { loadMicroApp } from 'qiankun';
loadMicroApp(
  { 
    name: 'app', 
       entry: '//localhost:7100',
    container: '#yourContainer', 
  }
);

子应用配置

首先在入口文件中暴露生命周期钩子函数,bootstrap 只会在子应用首次加载时触发一次,在这里可以初始一些子应用的全局数据;mount 会在每次子应用加载时都触发,在这里常用来触发子应用的渲染方法,同时接收主应用下发的 props ,进行父子通信;unmount 会在每次子应用卸载时都触发,在这里卸载微应用的应用实例,清空一些全局数据。

export async function bootstrap() {
  console.log('app bootstraped');
}

export async function mount(props) {
  console.log('app mount');
  console.log('props from main framework', props);
}

export async function unmount() {
  console.log('app unmount')
}

其次,在 webpack 打包配置文件中添加一些识别的配置信息,这里以官网的 vue 子应用作为例子:

const { name } = require('./package');
module.exports = {
  devServer: {
    headers: {
      'Access-Control-Allow-Origin': '*', // 开启应用间的跨域访问
    },
  },
  configureWebpack: {
    output: {
      library: `${name}-[name]`, // 名称的匹配
      libraryTarget: 'umd',// 把微应用打包成 umd 库格式
      jsonpFunction: `webpackJsonp_${name}`,
    },
  }
};

主应用和子应用通信

在乾坤中,提供了两种方式进行主应用和子应用的通信:

方式一:提供了发布订阅模式实现父子应用的通信,提供了 initGlobalState 方法定义全局状态,并返回通信方法。主应用和子应用中都可以通过 setGlobalState 按一级属性设置全局状态,并通过 onGlobalStateChange 方法监听全局状态的变化,有变更触发 callback,一般在主应用中进行全局状态的初始化,子应用中进行监听获取。

方式二:提供了基于 props 传参的模式,主应用将需要传递给子应用的属性和方法通过 props 的形式进行传递,子应用在生命周期的钩子函数中进行接收,是一种单向数据流模式。

考虑到业务中一般只需要主应用中给子应用进行登录信息、用户信息等相关全局信息的传递,且希望子应用能够保持相对独立,减少与主应用的耦合,决定采用方式二的形式进行通信。为了方便全局状态的统一管理,对父子应用的通信模块进行了二次封装,具体的使用方式如下:

第一步:主应用中封装应用通信类

// sharedStore.js
// 下面都是伪代码,提供大致的设计思路和使用方式,具体的使用方式跟框架有关,后面具体例子中会有详细介绍

import store from './xxx'; // 引入全局store,根据框架不同方式不一样
import utils from './xxx' // 引入共有工具函数

class SharedStore {

  /**
   * 获取 global model
   */
  getGlobal() {
    const { global } = store.getState(); // 获取store中的global
    return global || {};
  }

  /**
   * 子定义方法例子
   */
  fetchDictData(dict) {
    return store.dispatch({
      type: "dict/fetchDictData",
      payload: dict
    });
  }
  
   /**
   * 获取共有工具函数例子
   */
  getSharedUtils() {
   const { commonErrorHandler, myAjax } = utils;
   return {
           commonErrorHandler,
        myAjax
   }
  }
}

const sharedStore = new SharedStore();
export default sharedStore;

第二步:主应用通过props传递

在主应用中注册子应用的配置时,将需要下发给子应用的属性或是方法,通过props参数传递给子应用。子应用在生命周期的钩子函数的入参中就能拿到对应的props,再根据需要自行进行存储使用。在这里,我们把刚刚在主应用中封装好的 sharedStore 实例传递给子应用。

// 主应用中的入口文件
import sharedStore from '../src/utils/sharedStore.js'; // 主应用通信类文件

...
{
    name: 'xxx'
    entry: 'xxx',
    container: '#xxx',
    activeRule: 'xxx',
    props: {
      sharedStore
    }
  },
...

第三步:子应用在生命周期函数中接收props

子应用生命周期函数中的入参就是props,直接就可以从入参中拿到传递的属性或者方法,拿到之后根据业务需要进一步进行存储。举例来说,可以挂载到对应的全局实例中或是存在localstorage中,也可以混入子应用的store中作为初始化的值。在具体组件中需要使用时,能通过恰当的方式取到即可。在这里,我们在子应用中也写一个对应的 sharedStore 类,同时兼容子应用独立运行和在乾坤环境中运行:

// sharedStore.js
import store from './xxx'; // 子应用自身store,根据框架不同方式不一样
import utils from './xxx' // 子应用自身工具函数
import dictData from './xxx' // 子应用自定义数据对象
import { getDvaApp } from 'umi';
import { get } from './request';

// 独立运行时的数据管理
class selfStore {
  /**
   * 获取 global model
   */
  getGlobal() {
    const { global } = store.getState(); // 从子应用的store中获取
    return global || {};
  }


    /**
   * 自定义方法例子
   */
  fetchDictData(dict) {
    return {
        dictData: {
          ...dictData
      }
    }
  }
  
  /**
   * 获取共有工具函数
   */
  getSharedUtils() {
       return {
        ...utils
    }
  }
}

// 兼容独立运行和qinkun子应用的全局数据
class SharedStore {
  static store = new selfStore();

  /**
   * 重载 store
   */
  static overloadStore(store) {
    SharedStore.store = store;
  }

  /**
   * 获取 store 实例
   */
  static getStore() {
    return SharedStore.store;
  }
}

export default SharedStore;

然后在子应用生命周期函数中调用 sharedStore 类重载 store 数据:

// app.js
import sharedStore from './utils/sharedStore.js'; // 子应用通信类文件 

export async function mount(props) {
   sharedStore.overloadStore(props.sharedStore); // props.sharedStore 是主应用中传递的共享数据
}

这样之后,不论子应用是独立运行或是在乾坤的环境中,如果要用到 shareStore 中的属性和方法,都统一从 shareStore 的 getStore 方法中获取就可以了。

各个应用样式隔离

这个问题乾坤框架做了一定的处理,在运行时有一个sandbox的参数,默认情况下沙箱可以确保单实例场景子应用之间的样式隔离,但是无法确保主应用跟子应用、或者多实例场景的子应用样式隔离。如果要解决主应用和子应用的样式问题,目前有2种方式:

  1. 在乾坤种配置 { strictStyleIsolation: true } 时表示开启严格的样式隔离模式。这种模式下 qiankun 会为每个微应用的容器包裹上一个 shadow dom 节点,从而确保微应用的样式不会对全局造成影响。但是基于 ShadowDOM 的严格样式隔离并不是一个可以无脑使用的方案,大部分情况下都需要接入应用做一些适配后才能正常在 ShadowDOM 中运行起来,这个在 qiankun 的 issue 里面有一些讨论和使用经验。
  2. 人为用 css 前缀来隔离开主应用和子应用,在组件层面用 css scoped进行组件层面的样式区分,在 css框架层面可以给css组件库加上不同的前缀,比如文档中的 antd 例子:
  3. 配置 webpack 修改 less 变量
{
  loader: 'less-loader',
+ options: {
+   modifyVars: {
+     '@ant-prefix': 'yourPrefix',
+   },
+   javascriptEnabled: true,
+ },
}

b. 配置 antd ConfigProvider

import { ConfigProvider } from 'antd';
   
export const MyApp = () => (
  <ConfigProvider prefixCls="yourPrefix">
    <App />
  </ConfigProvider>
);

公共资源的处理

公共资源的处理,大致可以分为两类:第三方依赖库、通用方法和组件,乾坤本身目前还没有提供官方的解决方案(文档中说后续会有),所有目前都是使用者自己进行处理的。在方案设计中考虑到需要支持子应用在乾坤环境中和单独运行时,下面分别展开说一下各自的处理方法:

第三方依赖库

指的是像react、lodash这样的在主应用和子应用中都会用到的通用依赖包,在主应用中引入 cdn 资源,因为在乾坤应用中,所有子应用能访问到主应用中引入的 cdn 资源,同时乾坤本身对外链资源作了缓存,外链内容请求到之后,会记录到一个全局变量中,下次再次使用,会先从这个全局变量中取,解决了重复加载的问题。同时在子应用中,需要配置 webpack 的 external 参数,当在乾坤环境中,external中配置第三方依赖库,这样子应用打包时就不会把这部分的npm包资源打包到node_module中,减少应用包的体积;当在独立运行时,external配置为空,这样打包时会正常打包对应的npm包资源。

// webpack配置文件

module.exports = {
  //...
  externals: {
    react: 'react'
  }
};

通用方法和组件

指的是在父子应用或是多个子应用中共享的组件或函数方法,对于这种资源的处理有2种方式:

  1. 发布到 npm 包平台,需要用到的应用自行安装 npm 包,这是一种广泛运用的组件共享方式。
  2. 通过父应用 props 下发给子应用,子应用拿到后进行使用。为了通用化这种使用模式,可以在主应用中封装一个插件统一管理需要共享给子应用的组件,将这个插件通过 props 下发给子应用,子应用安装成全局组件后使用即可。以 vue 框架为例,首先在主应用中导出一个插件用来给子应用注册全局组件:
// shareComponent.js, 主应用中的共享组件管理插件

import SideBar from '../components/sideBar' //自定义公共组件
import TopBar from '../components/topBar' //自定义公共组件

const shareComponents = [SideBar, TopBar]; // 需要共享给子应用的组件
//vue插件的install方法
const install = function (Vue) {
  shareComponents.forEach(component => {
    Vue.component(component.name, component); //注册为Vue全局组件
  });
};

export default {
  install,
};

然后将这个插件作为props下发给子应用:

// 主应用中的入口文件
import shareComponent from '../src/utils/shareComponent.js'

...
{
    name: 'xxx'
    entry: 'xxx',
    container: '#xxx',
    activeRule: 'xxx',
    props: {
          shareComponent
    }
  },
...

接着在子应用的钩子函数中进行接收并安装

// 子应用入口文件
...
export async function mount(props) {
  console.log('[vue] props from main framework', props);
  Vue.use(props.shareComponent)
}
...

最后在子应用的任意页面中就可以正常使用:

// 子应用的一个组件中
<template>
      <TopBar></TopBar>
</template>

主应用和子应用的路由模式

不管是 vue 还是 react 应用,路由模式都有 history 和 hash 两种模式,主应用和子应用可以自由搭配不同的路由模式,一共会出现 2 * 2 = 4 种搭配,从使用角度来说,4种搭配都是可以正常使用,只是不同模式下 主应用下的 activeRule 和 子应用下的 route 的 basePath 会有不同,同时子应用内的路由跳转方式需要做相应的调整。经过对比这几种模式的优缺点,最终采用的是主应用history 模式搭配子应用 hash 模式。这么选择的原因是:

  1. 主应用用history模式,可以兼容子应用的hash或history模式,不需要调整旧项目的子应用内部的路由跳转方式
  2. 如果是子应用是新项目,用hash模式搭配主项目的history模式,模式清晰,从url上就能直观区分出当前是处在主应用还是子应用的页面。
  3. 本身项目中没有子应用互相跳转的场景,子应用的hash模式满足了当下需求,如果后续有跳转需求,可以通过调用主应用下发的跳转方法通过主应用来跳转。

子应用错误处理

乾坤提供了全局错误监听方法,在主应用中开启后,可以监听到所有子应用中抛出的异常,根据业务需要在错误回调函数中进行自定义处理即可。

// app.js
import { addGlobalUncaughtErrorHandler } from 'qiankun';
addGlobalUncaughtErrorHandler(event => console.error('子应用异常', event));

应用的部署

主应用和子应用可以部署在同一个服务器下,通过目录层级进行区分;也可以部署在不同的服务器上,这时主应用的就需要通过nginx做一次反向代理,把指向子应用的访问路径转发到子应用的服务入口。部署这一块官网的文档上介绍得十分清楚,在这里就不再多说了,详见 乾坤应用的部署

其他

子应用是否运行在乾坤中

标识:window.__POWERED_BY_QIANKUN__

嵌套子应用

这是一种较为复杂的场景,暂时没有遇到需要的场景,在本地跑了下demo,通过,此外这篇文章中给出了两种解决方案:qiankun的嵌套。在qiankun 的 issue里也有很多关于子应用嵌套的讨论,整体的结论是qiankun是支持的,但是因为工程和业务场景的不同可能会遇到bug,这点需要结合实际项目会有更大的发言权,在这里就不展开了。

接入文档

根据我们团队现有的前端应用现状,接入文档分成umi应用和其他应用(包括vue和react)两大类型来介绍。

umi应用

umi应用有个特点是umi框架通过插件的模式做了react - dva - layout 这种从框架层,到ui层的高度封装,开发者不需要关注实现的细节,只需要按照规范进行使用即可,而同样地,umi应用通过 @umijs/plugin-qiankun 插件(文档链接)对乾坤框架进行来集成,因此用法需要单独说明,下面的umi的例子,基于默认路由和侧边栏layout布局。

第一步:安装 @umijs/plugin-qiankun 插件

npm install --save @umijs/plugin-qiankun

第二步:主应用配置

  1. 插件注册并装置子应用
  2. 如果配置是写死的,不需要从接口获取,且 props 里面传递的共享数据也跟 store 或是接口无关,可以直接在 umi 的 config.js 文件中进行配置
//config.js文件
import { defineConfig } from 'umi';

export default defineConfig({
    qiankun: {
    master: {
      // 注册子应用信息
      apps: [
        {
          name: 'app-organization', // 唯一 id
          entry: '//localhost:7001', // html entry
          props: {
              ...
          }
        },
        {
          name: 'app-platform',
          entry: '//localhost:7002',
           props: {
              ...
          }
        },
      ],
      
      // 使用路由绑定的方式装置子应用
      // 子应用的routes,会被合并到主应用的routes中
      routes: [ 
        {
          path: '/app-organization',
          microApp: 'app-organization',
          microAppProps: {
            autoSetLoading: true, // 开启子应用页面自动loading
          }
        },
        {
          path: '/app-platform',
          microApp: 'app-platform',
          microAppProps: {
            autoSetLoading: true,
          }
        }   
      ]
    },
  },
    ......
})

b. 否则,需要把 乾坤的 配置写在 主应用的 app.js 文件中,在运行时进行加载,运行时加载的参数会合并到 config.js 中。

//app.js
import sharedStore from '../src/utils/sharedStore.js'; // 主应用通信类文件

export const qiankun = {
    apps: [
      {
        name: 'app-organization', // 唯一 id
        entry: '//localhost:8001', // html entry
        props: {
          sharedStore, //因为 sharedStore 与主应用 store 相关,所以要写在 app.js 中
        }
      },
      {
        name: 'app-platform',
        entry: '//localhost:8002',
        props: {
          sharedStore,
        }
      },
    ],
}
  1. 封装sharedStore

    封装的目的和思路在上文中已经有说明,在这里给出在umi中具体的应用例子:

// sharedStore.js
import { getDvaApp } from 'umi';
import utils from './utils' // 引入共有工具函数

class SharedStore {

  /**
   * 获取 global model
   */
  getGlobal() {
    const dva = getDvaApp();
    const { global } = dva._store.getState();
    return global || {};
  }

  /**
   * 更新字典数据
   */
  fetchDictData(dict) {
    const dva = getDvaApp();
    return dva._store.dispatch({
      type: "dict/fetchDictData",
      payload: dict
    });
  }
  
   /**
   * 获取共有工具函数
   */
  getSharedUtils() {
   const { commonErrorHandler, myAjax } = utils;
   return {
           commonErrorHandler,
        myAjax
   }
  }
  
}

const sharedStore = new SharedStore();
export default sharedStore;
  1. 修改菜单中子应用页面路径

这里的意思是将左侧菜单中对应的页面路径中关于子应用的要进行处理加上 "/app-organization" 或 "/app-platform" 前缀,这样才能指向子应用。这里的处理可以改接口返回中的路径地址,也可以在前端写一个统一处理函数进行处理。同时如果在页面的业务逻辑中有手动通过 history.push 进行跳转到子应用的地方,也要进行前缀处理。

  1. 修改主应用页面挂载元素

因为 @umijs/plugin-qiankun 插件的默认页面挂载元素的id是 root-master,所以如果用了默认的 document.ejs 模版,需要将模版中 body 元素下的挂载节点 id 从 "root",修改成 "root-master"。同时如果有全局样式 global.css,也需要将其中的样式 "#root" 修改成 "#root-master"。

// document.ejs文件
<html>
<head>
  <meta charSet="utf-8" />
  <title>Your App</title>
</head>
<body>
  <div id="root-master"></div>
</body>
</html>

5.应用端口配置(可选)

因为要同时启动主应用和多个子应用,为了端口管理的方便,可以在 src 下新建一个 .env 文件,在文件中写死应用启动的端口,这样每次启动时端口是固定的,不会根据应用启动的先后发生变化。

// .env文件
PORT=8000

第三步:子应用配置

  1. 插件注册
// config.js
import { defineConfig } from 'umi';
export default defineConfig({
    qiankun: {
    slave: {}
  }
  ...
})
  1. 配置运行时生命周期钩子
// app.js
import sharedStore from './utils/sharedStore.js'; // 子应用通信类文件 

export const qiankun = {
  // 应用加载之前
  async bootstrap(props) {
    console.log('app-platform bootstrap', props);
  },
  // 应用 render 之前触发
  async mount(props) {
    console.log('app2-platform mount', props);
    // 1.重写类的方式将主应用中传递的实例混入子应用中
    sharedStore.overloadStore(props.sharedStore); // props.sharedStore 是主应用中传递的全局数据
  },
  // 应用卸载之后触发
  async unmount(props) {
    console.log('app2-platform unmount', props);
  },
};

3.封装sharedStore

// sharedStore.js
import { getDvaApp } from 'umi';
import { get } from './request';

// 独立运行时的数据管理
class selfStore {
  /**
   * 获取 global model
   */
  getGlobal() {
    // 从store中加载
    const dva = getDvaApp();
    const { global } = dva._store.getState();
    return global || {};
  }

    /**
   * 更新字典数据
   */
  fetchDictData(dict) {
    return {
        dictData: {
          APP_1: 'SP',
        APP_2: 'SE',
        APP_3: 'SL',
      }
    }
  }
  
  /**
   * 获取共有工具函数
   */
  getSharedUtils() {
       return {}
  }
}

// 兼容独立运行和qinkun子应用的全局数据
class SharedStore {
  static store = new selfStore();

  /**
   * 重载 store
   */
  static overloadStore(store) {
    SharedStore.store = store;
  }

  /**
   * 获取 store 实例
   */
  static getStore() {
    return SharedStore.store;
  }
}

export default SharedStore;

4.应用端口配置(可选)

// .env文件
PORT=8001

第四步:应用部署

该项目中的主应用和子应用的前端部署走的是 oss 访问的方式,umi 应用打包后会生成一份 index.html、umi.js 和 umi.css 文件,只需要把这份文件放置在 oss 文件路径下,浏览器访问对应文件路径下的 index.html文件可。因此生产环境和本地开发不同的有两点:1.主应用配置中的 publicPath 要调整到 oss 的根目录(即放置 umi.js 和 umi.css 的目录),而本地开发时 publicPath 对应的是 '/'。2.主应用中 qiankun 的 app 入口配置要调整成子应用 index.html 对应的 oss 。

vue应用

第一步:主应用安装 qiankun

npm i qiankun -S

第二步:主应用配置

  1. 配置入口文件,挂载子应用
  2. 主应用中使用 vue-router 进行路由管理,如果不需要 vue-router,去掉相关的配置即可
  3. 启动乾坤的 start 方法放到 $nextTick 中调用,为了避免出现主应用还没挂载完成,子应用找不到其页面挂载元素而报错
  4. shareComponent、sharedStore的方案是通用的,在上文已经介绍过,不再重复展开。
import Vue from 'vue'
import App from './App.vue'
import VueRouter from 'vue-router'
import routes from './routes'

import  shareComponent from '../src/utils/shareComponent'; // 公享组件
import sharedStore from '../src/utils/sharedStore.js'; // 共享通信类文件
import { registerMicroApps, start } from 'qiankun';


Vue.use(VueRouter)

Vue.config.productionTip = false

const router = new VueRouter({
  routes,
  mode: "history",
})
const vueApp = new Vue({
  router,
  render: h => h(App),
}).$mount('#app')


registerMicroApps([
  {
    name: 'slave-one', // app name registered
    entry: '//localhost:8082',
    container: '#slave-view',
    activeRule: '/qiankun-demo-slave-one',
    props: {
      sharedStore,
      shareComponent,
    }
  },
  {
    name: 'slave-two', // app name registered
    entry: '//localhost:8083',
    container: '#slave-view',
    activeRule: '/qiankun-demo-slave-two',
    props: {
      sharedStore,
      shareComponent,
    }
  },
  {
    name: 'slave-three',
    entry: '//localhost:3000',
    container: '#slave-view',
    activeRule: '/qiankun-demo-slave-three',
    props: {
      sharedStore,
      shareComponent,
    }
  },
]);

vueApp.$nextTick(()=>{
  start(); //为了避免出现主应用还没挂载完成,子应用找不到 #slave-view 元素而报错
})
  1. 配置子应用在页面中的加载位置
  2. 页面的布局固定好左侧菜单和上方导航组件,中间的部分进行页面内容的展示
  3. 通过 $route.name 的有无来判断是加载主应用还是子应用的页面,当主应用中的路由中匹配当前url则是主应用自身的页面,否则展示子应用页面。
// App.vue
<template>
  <div id="app">
    <div class="left-menu">
       <sideBar></sideBar>
    </div>
    <div class="right-content">
      <topBar></topBar>
      <div class="main-content">
          <div id="master-view" v-if="$route.name">
            <router-view></router-view>
          </div>
          <div id="slave-view" v-else></div>
      </div>
    </div>
    
  </div>
</template>

<script>
import sideBar from './components/sideBar';
import topBar from './components/topBar';

export default {
  name: 'App',
  components: {
    sideBar,
    topBar
  },
}
</script>
...

第二步:子应用配置

  1. 配置 public-path.js 文件
if (window.__POWERED_BY_QIANKUN__) {
  __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__; // 这是为了在乾坤环境下动态设置 webpack 的 publicPath,防止资源加载出错
}
  1. 配置入口文件
  2. 在入口文件中引入 public-path.js
  3. 子应用如果也用了vue-router,要注意new vueRouter() 的 base 在乾坤环境下要加上和主应用中配置的 activeRule 一致的前缀,一般可以约定都用子应用的工程名字。否则子应用内部的路由跳转会出错。
  4. 在子应用的 unmount 钩子中一定要卸载元素,否则会有多个子应用切换时的bug,这个在乾坤的issue里有提到
import './public-path'
import Vue from 'vue'
import App from './App.vue'
import VueRouter from 'vue-router'
import routes from './routes'

Vue.use(VueRouter)
Vue.config.productionTip = false;

const packageName = require("../package.json").name; // 统一规范,取工程名字,且必须跟主应用配置下的 activeRule 一致
let router = null;
let instance = null;

function render(props = {}) {
  const { container } = props;
  router = new VueRouter({
    base: window.__POWERED_BY_QIANKUN__ ? `/${packageName}` : '/',
    mode: 'hash',
    routes,
  });
  instance = new Vue({
    router,
    render: h => h(App),
  }).$mount(container ? container.querySelector('#app') : '#app'); // 挂载时局部进行查找,避免多个子应用的相互影响
}
// 独立运行时
if (!window.__POWERED_BY_QIANKUN__) {
  render();
}

// 微应用必须暴露的三个加载生命周期hooks
export async function bootstrap() {
  console.log('[vue] vue app bootstraped');
}
export async function mount(props) {
  console.log('[vue] props from main framework', props);
  Vue.use(props.shareComponent) // 注册共享组件
  render(props); // 渲染应用
}
export async function unmount() {
  instance.$destroy();
  instance.$el.innerHTML = '';
  instance = null;
  router = null;
}

3.配置webpack

const { name } = require('./package');
module.exports = {
  devServer: {
    headers: {
      'Access-Control-Allow-Origin': '*',
    },
  },
  configureWebpack: {
    output: {
      library: `${name}-[name]`,
      libraryTarget: 'umd',// 把微应用打包成 umd 库格式
      jsonpFunction: `webpackJsonp_${name}`,
    },
  },
};

第三步:应用部署

主应用和子应用在不同服务器独立部署,具体方案在上文中已经有说明,跟着官方文档操作即可。

react应用

流程跟 vue 应用大同小异,参考上方的 vue 配置流程例子和官网的教程:react微应用

总结

经过对老项目(umi工程)对微前端实际改造,以及本地新项目(vue工程)对demo的测试。借助乾坤框架 + 自定义的封装处理,基本上实现了微前端的应用的平稳接入。总体感受,微前端确实能以一种优雅的方式解决前端工程臃肿和多人协作开发的问题。但是需要注意的是,是否需要微前端要从项目实际情况出发,比如简单的单个应用根本不需要采用,反而会增加开发成本。此外微前端的子应用的拆分方式和力度也需要从实际项目出发,并不是越细越好,适合实际的才是最好的。

参考资料

乾坤官方文档

qiankun issue

@umi/plugin-qiankun文档

qiankun 微前端方案实践及总结(一)

qiankun 微前端实践总结(二)

让前端走进微时代, 微微一弄很哇塞!

查看原文

赞 2 收藏 2 评论 0

款冬 回答了问题 · 2020-11-22

http 缓存问题

缓存针对的是资源文件,如图片、css文件、js文件、字体文件等,并不是说设置了缓存之后,请求就不发送了。只是服务端接受到了带缓存头的请求后,会根据缓存规则进行对应的处理。

https://www.jianshu.com/p/54cc04190252

关注 2 回答 1

款冬 回答了问题 · 2020-11-22

解决无效的import 会对打包有印影响吗?

首先明确下这个 “无效的” 的定义,是路径不对,加载不到的资源,还是引进来了资源,但是实际上在代码中没有用到。

如果是前者
可以理解为对打包几乎没有影响,只是在打包的时候会去查找这个资源,但是查不到,因此对最后的打包体积不会有影响,对打包速度会有轻微影响,可以忽略不计(因为多了去查找资源的过程)

如果是后者
会对打包的速度和体积有影响,webpack的打包过程会对从入口文件开始,依次读取依赖,碰到 require 和 import的资源,最终都会打包到最后的产出bundle中。不过这个问题可以通过webpack的配置 tree shaking 来解决,打包时会删除没用到的方法和文件。

关注 2 回答 1

款冬 发布了文章 · 2020-09-13

egg-core源码解读

前言

egg框架的使用过程中会发现有一些非常方便和优雅的地方,比如对各个环境下配置的合并和加载,对controller,service,middleware的集成和建立关联,对插件扩展等,从源码中可以发现egg是继承于egg-core的,而这些核心逻辑的实现都是在egg-core里完成的,因此可以说egg框架的核心在于egg-core。下面就对egg-core的源码进行一些解读,来体会框架设计的精妙之处。

模块关系

egg-core源码的入口导出了EggCore、EggLoader、BaseContextClass、utils 四个模块,其中EggCore类是基类,做一些初始化工作;EggLoader类是最核心的一个部分,对整个框架的controller,service,middleware等进行初始化和加载集成,并建立相互关联。BaseContextClass是另一个基类, 用来接收ctx对象,挂载在上下文上,egg框架中的 controller 和 service 都继承该类,所以都能通过this.ctx拿到上下文对象。utils则是定义了一些框架中用到的方法。看一张图片会比较清晰:
image

核心模块

egg-core

egg-core继承于 Koa ,在该类中主要完成一些初始化工作,大概可以分为

  1. 对初始化参数的处理,包括对传入的应用的目录,运行的类型的判断等。
constructor(options = {}) {
    options.baseDir = options.baseDir || process.cwd();
    options.type = options.type || 'application';

    assert(typeof options.baseDir === 'string', 'options.baseDir required, and must be a string');
    assert(fs.existsSync(options.baseDir), `Directory ${options.baseDir} not exists`);
    assert(fs.statSync(options.baseDir).isDirectory(), `Directory ${options.baseDir} is not a directory`);
    assert(options.type === 'application' || options.type === 'agent', 'options.type should be application or agent');
  1. 关键属性的初始化和挂载,包括Controller、Service、lifecycle、loader、router等。
this.console = new EggConsoleLogger();
this.BaseContextClass = BaseContextClass;
const Controller = this.BaseContextClass;
this.Controller = Controller;
const Service = this.BaseContextClass;
this.Service = Service;
this.lifecycle = new Lifecycle({
      baseDir: options.baseDir,
      app: this,
      logger: this.console,
    });
 this.loader = new Loader({
      baseDir: options.baseDir,
      app: this,
      plugins: options.plugins,
      logger: this.console,
      serverScope: options.serverScope,
      env: options.env,
    });
  1. 生命周期函数的初始化和监听,中间件use方法的定义。
beforeStart(scope) {
    this.lifecycle.registerBeforeStart(scope);
}
ready(flagOrFunction) {
    return this.lifecycle.ready(flagOrFunction);
}
beforeClose(fn) {
    this.lifecycle.registerBeforeClose(fn);
}

use(fn) {
    assert(is.function(fn), 'app.use() requires a function');
    debug('use %s', fn._name || fn.name || '-');
    this.middleware.push(utils.middleware(fn));
    return this;
}

egg-loader

整个框架目录结构(controller,service,middleware,extend,router)的加载和初始化工作都在该类中实现的。egg-loader中定义了一系列初始化的全局方法和加载loader的基础方法。将所有分开写在各个文件中的loader方法统一在该类中引入进行加载,会根据目录结构规范将目录结构中的 config,controller,service,middleware,plugin,router 等文件 load 到 app 或者 context 上,最终挂载输出内容。开发人员只要按照这套约定规范,就可以很方便进行开发。

// 加载文件
  loadFile(filepath, ...inject) {
    filepath = filepath && this.resolveModule(filepath);
    if (!filepath) {
      return null;
    }

    // function(arg1, args, ...) {}
    if (inject.length === 0) inject = [ this.app ];

    let ret = this.requireFile(filepath);
    if (is.function(ret) && !is.class(ret)) {
      ret = ret(...inject);
    }
    return ret;
  }
  
requireFile(filepath) {
    const timingKey = `Require(${this[REQUIRE_COUNT]++}) ${utils.getResolvedFilename(filepath, this.options.baseDir)}`;
    this.timing.start(timingKey);
    const ret = utils.loadFile(filepath);
    this.timing.end(timingKey);
    return ret;
  }
// 挂载到App上
  loadToApp(directory, property, opt) {
    const target = this.app[property] = {};
    opt = Object.assign({}, {
      directory,
      target,
      inject: this.app,
    }, opt);

    const timingKey = `Load "${String(property)}" to Application`;
    this.timing.start(timingKey);
    new FileLoader(opt).load();
    this.timing.end(timingKey);
  }
  
// 挂载到上下文上
loadToContext(directory, property, opt) {
    opt = Object.assign({}, {
      directory,
      property,
      inject: this.app,
    }, opt);

    const timingKey = `Load "${String(property)}" to Context`;
    this.timing.start(timingKey);
    new ContextLoader(opt).load();
    this.timing.end(timingKey);
 }

在加载的过程中会用到 file_loader 和 context_loader 两个类,这两个是Load加载流程中的基础类,提供了基础的加载上下文和方法,这个在后面会有细说。

具体loader模块

err-loader中会将具体的一些loader模块require进来并挂载在原型链上,这样在err-loader中就可以访问到具体的loader模块中的方法。这些loader模块包括plugin、config、service、middleware、controller等,分别负责各自的一些逻辑,并且模块间也存在着相关的联系。下面这张图中大致标注出了各个loader的依赖关系,比如加载 middleware 时会用到 config 关于应用中间件的配置,对内部中间件进行 use 的主动加载。具体的关系图如下:
image
下面会挑其中的重点Loader进行具体分析:

plugin_loader

首先来看插件加载模块,该模块是err-core中一个非常重要的模块,egg-core中的插件大致可以分为3类:框架级插件、应用级插件、用户自定义插件。这3种插件如何进行共存和覆盖,如何根据环境变量和开启开关进行加载?这就是plugin-loader中做的控制。该模块整体做了4件事情:

  1. 从以上说的3类插件的目录中读取插件,并按照框架级插件、应用级插件、用户自定义插件的顺序进行插件的加载和覆盖,后面的插件会覆盖前面的插件,得到最后合并后的插件。
  2. 根据当前环境变量和插件的配置对插件是否开启进行处理,因为有一些插件只有在特定的环境下才会开启。
  3. 对所有的框架进行依赖关系的检查和相应的处理,如果有依赖插件的缺失或者循环引用,会抛出错误。如果有依赖关系的插件没有开启,那么也会将改插件开启。
  4. 经过以上3步处理后,将最终符合开启条件的插件对象挂载在 this 对象上,完成插件的处理流程。

file_loader

这是一个基础的loader模块,通过提供一个 load 函数对工程的目录结构和文件内容进行解析,这个函数如下,其核心在于调用了parse方法对文件路径进行解析,对解析后的数组中的对象的属性进行重新分割处理。

load() {
    const items = this.parse();
    //items的形式: [{ properties: [ 'a', 'b', 'c'], exports1,fullpath1}, { properties: [ 'a', 'b', 'c'], exports2,fullpath2}]
    const target = this.options.target;
    for (const item of items) {
      debug('loading item %j', item);
      // item { properties: [ 'a', 'b', 'c'], exports }
      // => target = {a: {b: {c: exports1, d: exports2}}}
      // => target.a.b.c = exports
      item.properties.reduce((target, property, index) => {
        let obj;
        const properties = item.properties.slice(0, index + 1).join('.');
        if (index === item.properties.length - 1) {
          if (property in target) {
            if (!this.options.override) throw new Error(`can't overwrite property '${properties}' from ${target[property][FULLPATH]} by ${item.fullpath}`);
          }
          obj = item.exports;
          if (obj && !is.primitive(obj)) {
            obj[FULLPATH] = item.fullpath;
            obj[EXPORTS] = true;
          }
        } else {
          obj = target[property] || {};
        }
        target[property] = obj;
        debug('loaded %s', properties);
        return obj;
      }, target);
    }
    return target;
  }

对应的parse方法如下,代码中已经进行了关键语句的注释

  parse() {
    //最终生成 [{ properties: [ 'a', 'b', 'c'], exports,fullpath}] 形式, 
    //properties 文件路径名称的数组, exports 是导出对象, fullpath 是文件的绝对路径
    let files = this.options.match;
    if (!files) {
      files = (process.env.EGG_TYPESCRIPT === 'true' && utils.extensions['.ts'])
        ? [ '**/*.(js|ts)', '!**/*.d.ts' ]
        : [ '**/*.js' ];
    } else {
      files = Array.isArray(files) ? files : [ files ];
    }

    let ignore = this.options.ignore;
    if (ignore) {
      ignore = Array.isArray(ignore) ? ignore : [ ignore ];
      ignore = ignore.filter(f => !!f).map(f => '!' + f);
      files = files.concat(ignore);
    }

    //文件目录转换为数组
    let directories = this.options.directory;
    if (!Array.isArray(directories)) {
      directories = [ directories ];
    }

    const filter = is.function(this.options.filter) ? this.options.filter : null;
    const items = [];
    debug('parsing %j', directories);
    for (const directory of directories) {
      //每个文件目录下面可能还会有子文件夹,所以 globby.sync 函数是获取所有文件包括子文件下的文件的路径
      const filepaths = globby.sync(files, { cwd: directory });
      for (const filepath of filepaths) {
        // 拼接成完整文件路径
        // app/service/foo/bar.js
        const fullpath = path.join(directory, filepath);
        // 如果不是文件跳过,进行下一次循环
        if (!fs.statSync(fullpath).isFile()) continue;
        // get properties
        // foo/bar.js => ['foo', 'bar' ]
        const properties = getProperties(filepath, this.options);
       // app/service ['foo', 'bar' ] => service.foo.bar
        const pathName = directory.split(/[/\\]/).slice(-1) + '.' + properties.join('.');
        // get exports from the file
        const exports = getExports(fullpath, this.options, pathName);

        // ignore exports when it's null or false returned by filter function
        if (exports == null || (filter && filter(exports) === false)) continue;

        // set properties of class
        if (is.class(exports)) {
          exports.prototype.pathName = pathName;
          exports.prototype.fullPath = fullpath;
        }

        items.push({ fullpath, properties, exports });
        debug('parse %s, properties %j, export %j', fullpath, properties, exports);
      }
    }

    return items;
  }

}

最后会返回一个解析后的 target 对象,对象的层级结构跟目录结构相对应,最内层是文件的导出对象或者方法。大概的格式如下:

target = {
    a: {
        b: {
            c: exports1, 
            d: exports2,
            
        }
        
    }
    
}

导出的这个 target 对象会在 context_loader 里用到。service、controller的loader实现 均借助了该基类。

context_loader

context_loader类是用于处理上下文挂载的基类,继承于file_loader。上文说了 file_loader 的作用是对文件目录的解析生成 target 对象。而 context_loader 类就是在这基础上进一步实现了 target 对象的挂载。用于将 FileLoader 解析出来的 target 挂载在 app.context 上 对应传入的 property 属性上。

service_loader

service_loader处理service文件夹下文件的加载,该模块中直接就导出了一个 loadService 的方法。该方法把service的文件目录('app/service') 和 解析后需要挂载的属性('service')作为参数传入 egg_loader的 loadToContext 方法中,loadToContext方法会创建一个 ContextLoader 类的实例,并调用其load()方法,通过上文说的 file_loader 和 context_loader 中的核心逻辑,实现将 app/service 文件夹下的文件路径和导出解析成target对象,最终挂载在app.context.service下。

middleware_loader

中间件的加载,主要做了3件事:

  1. 将通过 FileLoader 实例加载 app/middleware 目录下的所有文件并导出,然后将 middleware 挂载在 app 上,可以通过app.middleware进行访问
  2. 对中间件函数进行包装,统一处理成async function(ctx, next) 形式
  3. 对在 config 中配置了 appMiddleware 和 coreMiddleware 的中间件直接调用 app.use 使用,其它中间件只是挂载在 app 上。

controller_loader

constroller的加载跟service还是有区别的:

  • constroller挂载在app上,service挂载在app.ctx上,constroller的调用只需要访问到对应的service名称,而service的调用需要具体到导出的函数,因此两者使用egg_loader中方法不同,一个是loadToApp(调用FileLoader实例),一个是LoadToContext(调用ContextLoader实例)
  • controller 中生成的函数最终还是在 router.js 中当作一个中间件使用,所以我们需要将 controller 中内容转换为中间件形式 async function(ctx, next) ,这跟service相比在调用FileLoad类实例的load函数时就要多传一个 initializer 函数,对exports的内容进行处理。

具体controller_loader类中做的事情也是围绕以上两点,解析 app/controller 文件目录生成targe对象,完成在app上对 controller 属性的挂载。同时对 initializer 函数进行了各种情况下的处理。

config_loader

config_loader对整个应用的配置加载做了管理,会根据当前环境的不同,加载不同的配置环境,并和默认的配置合并后得到最终的配置。config_loader对配置维度的加载有2个维度,大的维度来说,先会加载plugin的配置文件,再加载framework的配置文件,最后才是app的配置文件。小的维度来说会先从基本路径下的config/config 目录下加载默认的配置文件,然后根据当前的serverEnv的不同,加载不同环境的配置文件,最后将当前环境下的配置文件和默认的配置文件进行合并得到最终的配置文件。总结来说:先分别按照plugin、framework、app的顺序合并得到默认的配置和当前环境下的配置。然后用合并默认的配置和当前环境下的配置,得到的才是最终的配置。

router_loader

router_loader中其实就做了加载一下 app/router 目录下的文件而已。这是因为具体的router的逻辑,都交给了eggcore中的router属性,而而 router 又是 Router 类的实例,Router 类是基于 koa-router 实现的。所以egg中关于router的原理跟koa大致是相同的,这里就不展开说了。

总结

看完 egg-core 的源码之后,还是有很多收获的,在我看来有以下几点值得借鉴和思考:

  1. 规范和代码风格的重要性,在多人合作中这一点尤其重要。而egg-core则通过定义和实现了关于目录解析和属性挂载的这一套规范,解决了规范一致性的问题,同时通过controller、service的分层设计,让代码的可读性和易维护性也得到了大大增强。
  2. 框架的扩展和继承的设计,err-core本身是基础koa的,而本身也被egg继承。通过这种框架之间的继承可以根据实际需求方便地构建出需要的框架。而具体到里面的各个load类,也是通过先定义了 fileLoader 和 contextLoader 两个基类,被其他loader类频繁地进行依赖和调用。而 egg_loader类 和其他 loader 类也是解耦的,将其他 loader类 加载到egg_loader类的原型链上进行访问,入口统一,内容代码独立,体现了很好的设计思想。
  3. Symbol和Symbol.for的使用,对已有或缓存的内容的判断和加载通过了Symbol来实现,跟用全局Map对象进行维护更优雅和方便。
查看原文

赞 4 收藏 1 评论 0

款冬 发布了文章 · 2020-05-17

koa源码解读

前言

koa作为广泛运用的node框架,其源代码非常精简,看完之后愈发佩服TJ大神,能够用这么少的代码实现了如此强大易用的框架。下面结合源码具体分析一下其中的核心原理。

整体结构

首先看一下 koa 框架的组成结构,koa 的源码由4个部分组成,分别是 application.js(入口文件),context.js(上下文,即koa的ctx),request.js(请求对象,基于req封装),response.js(响应对象,基于res封装),其中核心代码主要都位于 application.js 中。下面会从4个文件展开具体的分析:

application

application.js 是 koa 的入口文件,也是核心所在。在该文件中引入了其他3个文件,并在构造函数中定义了一些核心属性,主要有

  • middleware:这是注册的中间件的集合
  • context:上下文模块,继承于context.js创建的对象
  • request:请求模块,继承于request.js创建的对象
  • response:响应模块,继承于response.js创建的对象
const context = require('./context');
const request = require('./request');
const response = require('./response');
....

constructor(options) {
    super();
    options = options || {};
    this.proxy = options.proxy || false;
    this.subdomainOffset = options.subdomainOffset || 2;
    this.proxyIpHeader = options.proxyIpHeader || 'X-Forwarded-For';
    this.maxIpsCount = options.maxIpsCount || 0;
    this.env = options.env || process.env.NODE_ENV || 'development';
    if (options.keys) this.keys = options.keys;
    this.middleware = []; // 中间件数组
    this.context = Object.create(context);// 上下文对象
    this.request = Object.create(request);// 请求对象
    this.response = Object.create(response);// 响应对象
    if (util.inspect.custom) {
      this[util.inspect.custom] = this.inspect;
    }
  }

接下来看koa的使用方法 listen ,listen 方法就是对 http.createServer 对了一个简单的封装,抽离出来单独的回调函数,返回 http 服务对传入端口的监听。node 原生的 http.createServer方法中需要传入处理的回调函数,但是在实际的复杂业务逻辑中,代码不可避免不好管理,因此 koa 这里对回调函数作了单独处理。

 listen(...args) {
    debug('listen');
    const server = http.createServer(this.callback());
    return server.listen(...args);
  }

下面继续看单独抽离的 callback 方法,该方法中主要做了3件事情:

  1. 对注册的中间件进行了统一整合处理
  2. 监听框架运行错误,并设置了错误处理函数
  3. 返回请求处理函数

下面具体来看这 3 个步骤:

 callback() {
    const fn = compose(this.middleware);

    if (!this.listenerCount('error')) this.on('error', this.onerror);

    const handleRequest = (req, res) => {
      const ctx = this.createContext(req, res);
      return this.handleRequest(ctx, fn);
    };

    return handleRequest;
  }

compose

该方法中的第一步就调用了 compose 方法对中间件进行了整合处理, compose方法是单独写的 koa-compose 模块,是koa中间件处理的核心所在,具体看一下处理逻辑,十分有意思:

function compose (middleware) {
  //入参必须是数组,且数组中的元素都必须是函数(每个中间件都是一个个的函数)
  if (!Array.isArray(middleware)) throw new TypeError('Middleware stack must be an array!')
  for (const fn of middleware) {
    if (typeof fn !== 'function') throw new TypeError('Middleware must be composed of functions!')
  }

  /**
   * @param {Object} context
   * @return {Promise}
   * @api public
   */

  return function (context, next) {
    // last called middleware #
    let index = -1
    return dispatch(0)
    function dispatch (i) {
      if (i <= index) return Promise.reject(new Error('next() called multiple times'))
      index = i
      let fn = middleware[i]
      if (i === middleware.length) fn = next
      if (!fn) return Promise.resolve()
      try {
        return Promise.resolve(fn(context, dispatch.bind(null, i + 1))); // 返回的方法中的第二个参数递归调用下一个中间件方法,这就是为什么中间件中执行next()时会调用下一个中间件函数
      } catch (err) {
        return Promise.reject(err)
      }
    }
  }
}

该方法中首先对传入的中间件集合入参做了判断,只能是数组,并且数组中的每个元素都必须是函数,这就要求注册的中间件必须是函数。 接下来就是核心所在,该方法返回一个函数,第一个参数 context 是请求上下文,第二个参数 next 是所有中间件执行完之后最终执行的回调函数。我们重点来看一下该方法中核心的dispatch函数的逻辑:

dispatch

dispatch函数会遍历 middleware 中间件集合,依次取出中间件进行执行,直到所有中间件都执行完成。fn(context, dispatch.bind(null, i + 1)) 这条语句是最关键的一条语句,执行当前中间件函数,将上下文context作为第一个参数传入,下一个要执行的中间件方法作为第二个参数传入。这就是为什么我们在 koa 中间件中执行next()方法(对应这里的第二个参数)时,会执行下一个中间件函数的原因,如果不调用next(),那么后面的中间件函数都会无法执行。

监听error

第二步,通过 this.on('error', this.onerror) 对框架中的 error 事件进行监听,对应的 onerror 处理函数分情况进行了相应的错误处理

 onerror(err) {
    if (!(err instanceof Error)) throw new TypeError(util.format('non-error thrown: %j', err));

    if (404 == err.status || err.expose) return;
    if (this.silent) return;

    const msg = err.stack || err.toString();
    console.error();
    console.error(msg.replace(/^/gm, '  '));
    console.error();
  }

返回handleRequest

createContext

先执行了 const ctx = this.createContext(req, res),创建了当前请求的上下文对象,createContext 方法做的事情就是创建了一个 context 对象,并且将当前的this、req、res都挂载到了该对象上,这也是为什么我们在使用 koa 时能在请求的 ctx 上拿到关于 app、req 和 res 上的各种请求相关属性的原因。

createContext(req, res) {
    const context = Object.create(this.context);
    const request = context.request = Object.create(this.request);
    const response = context.response = Object.create(this.response);
    context.app = request.app = response.app = this;
    context.req = request.req = response.req = req;
    context.res = request.res = response.res = res;
    request.ctx = response.ctx = context;
    request.response = response;
    response.request = request;
    context.originalUrl = request.originalUrl = req.url;
    context.state = {};
    return context;
  }
handleRequest

紧接着 return this.handleRequest(ctx, fn) 其中 ctx 就是上一步创建的请求上下文对象,fn 是 compose 返回的闭包函数。handleRequest方法最终 return fnMiddleware(ctx).then(handleResponse).catch(onerror); 即当所有中间件执行之后执行响应处理函数。

 handleRequest(ctx, fnMiddleware) {
    const res = ctx.res;
    res.statusCode = 404;
    const onerror = err => ctx.onerror(err);
    const handleResponse = () => respond(ctx);
    onFinished(res, onerror); // 一个监听请求结束的第三方库,发生错误时,执行默认错误回调函数
    return fnMiddleware(ctx).then(handleResponse).catch(onerror); // 所有中间件执行之后执行响应处理函数,若抛出异常,执行默认错误回调函数
  }
respond

接下来重点看一下上一步相应处理函数中用到的 respond 函数,该函数是 koa 中的又一个核心函数。主要就是针对不同的响应主体和状态,进行不同的处理,主要分为以下几种case:

  1. 没有响应主体时的处理
  if (statuses.empty[code]) { // 返回的状态码表示没有相应主体时
    // strip headers
    ctx.body = null;
    return res.end();
  }
  1. HEAD请求方法,响应头已经发送,但是没有内容长度时的处理
  if ('HEAD' === ctx.method) { // HEAD请求方法
    if (!res.headersSent && !ctx.response.has('Content-Length')) { // 响应头已经发送,但是没有内容长度,进行设置
      const { length } = ctx.response;
      if (Number.isInteger(length)) ctx.length = length;
    }
    return res.end();
  }
  1. 有相应主体,但是为空时的处理
 if (null == body) { // 有相应主体,但是为空
    if (ctx.response._explicitNullBody) {
      ctx.response.remove('Content-Type');
      ctx.response.remove('Transfer-Encoding');
      return res.end();
    }
    ...
  1. 有相应主体,不同格式的处理
 // responses,对不同的响应主体进行处理
  if (Buffer.isBuffer(body)) return res.end(body);
  if ('string' == typeof body) return res.end(body);
  if (body instanceof Stream) return body.pipe(res);

  // body: json
  body = JSON.stringify(body);
  if (!res.headersSent) {
    ctx.length = Buffer.byteLength(body);
  }
  res.end(body);

至此整个 koa 入口文件的主流程使用方法已经分析完成,除了这个主流程之外,还有个中间件的使用方法 use() 需要单独看一下。

use

use 方法是 koa 中注册中间件的方法,原理其实很简单,当调用 use 方法注册中间件时,实质上就是讲中间件函数 push 到 this.middleware 这个框架中间件集合中,所以中间件的执行是先进先出。并且函数最后返回了 this, 这么做是保证了中间件的注册可以实现链式调用。具体的代码和注释如下:

 use(fn) {
    if (typeof fn !== 'function') throw new TypeError('middleware must be a function!');
    // koa1.x版本使用Generator Function的方式写中间件,而Koa2改用ES6 async/await。所以在use()函数中会判断是否为旧风格的中间件写法,并对旧风格写法得中间件进行转换
    if (isGeneratorFunction(fn)) { 
      deprecate('Support for generators will be removed in v3. ' +
                'See the documentation for examples of how to convert old middleware ' +
                'https://github.com/koajs/koa/blob/master/docs/migration.md');
      fn = convert(fn);
    }
    debug('use %s', fn._name || fn.name || '-');
    this.middleware.push(fn);
    return this; // 返回this,所以可以链式调用
  }

context

接下来看 koa 的 context.js 文件,context 的核心在于:

  1. 封装了 koa 请求的上下文,代理了 request 和 response 这两个对象的属性和方法,用到的 delegate 方法是一个第三方库,用来代理对象的属性和方法。
delegate(proto, 'request')
  .method('acceptsLanguages')
  .method('acceptsEncodings')
  .method('acceptsCharsets')
  .method('accepts')
  .method('get')
  .method('is')
  
  ...
  
delegate(proto, 'response')
  .method('attachment')
  .method('redirect')
  .method('remove')
  .method('vary')
  .method('has')
  .method('set') 
  
  ...

2.定义了 onerror 错误处理函数,在之前 application.js 里面的 handleRequest 就有用到。该函数主要也是根据不同的情况做不同的处理:

onerror(err) {
    if (null == err) return;
    if (!(err instanceof Error)) err = new Error(util.format('non-error thrown: %j', err));
    
    ...
    
    if ('ENOENT' == err.code) err.status = 404;

    // default to 500
    if ('number' != typeof err.status || !statuses[err.status]) err.status = 500;

    // respond
    const code = statuses[err.status];
    const msg = err.expose ? err.message : code;
    this.status = err.status;
    this.length = Buffer.byteLength(msg);
    res.end(msg);
    
}

request && response

最后的请求和响应模块,没什么特别需要分析的,就是对请求和响应的相关属性和方法作了封装,用 set 和 get 函数的形式进行属性的读写操作。大致的封装代码如下:

get search() {
    if (!this.querystring) return '';
    return `?${this.querystring}`;
  },

set search(str) {
    this.querystring = str;
  },
  ...

洋葱圈模型

最后着重看下 koa 中间件请求的洋葱圈模型,这是 koa 区别于 express 的一个重大特点。 用一张网上经典的图片和一个简单的小例子说明一下: image

const Koa = require('koa');
const app = new Koa();

app.use(async (ctx, next) => {
    console.log(1);
    await next();
    console.log(6);
});

app.use(async (ctx, next) => {
    console.log(2);
    await next();
    console.log(5);
});

app.use(async (ctx, next) => {
    console.log(3);
    ctx.body = "last middleware";
    console.log(4);
});

app.listen(3000, () => {~~~~
    console.log('listenning on 3000');
});

//依次输出 1、2、3、4、5、6

为什么能实现这个效果呢,其实在前面介绍 koa-compose 源码中已经可以找到答案了。koa 中间件机制 return Promise.resolve(fn(context, dispatch.bind(null, i + 1))) 每次都返回一个promise,并在中间件方法中调用 next()时,对应执行下一个中间件。因此当 await next() 时会等待下一个中间件执行完成后,再回到当前中间件中继续执行后续代码。这也就是洋葱圈模型实现的原理。

查看原文

赞 1 收藏 0 评论 0

款冬 发布了文章 · 2020-04-25

Vue中的组件通信

前言

vue中的组件通信是必不可少的使用场景,回顾了平时使用vue中碰到的一些业务场景和对应采用的组件通信方式,简单地做了一个归类总结,大致有以下一些通信方式:

props/$emit

定义

最常用的父子组件通信方式,父组件通过props向子组件传值,子组件通过$emit事件向父组件触发事件并传值

实例说明
//父组件
<parent :name="xxx" @updateFromChild="xxx"></parent>

//子组件
props: {
    name: {
        type: String,
        default: ''
    }
}
methods: {
    xxx () {
        this.$emit('updateFromChild', xx)
    }
}

$attrs 和 \$listeners

$attrs:

定义

子组件中获取除了在props中定义之外的父组件中传递的属性值(class 和 style 除外)

实例说明
//父组件
<parent name="小明" age=5 sex="男" home="杭州" class="parent"></parent>

//子组件
<script>
props: {
    name: {
        type: String,
        default: ''
    }
}
mounted () {
    console.log(this.$attrs)
    // {age:5, sex:"男", home:"杭州"}
}
</script>
使用场景

当孙组件中需要用到爷组件中的大量属性,但是父组件却不需要用到很多,只是作为一个中转的传递,巧用$attrs可以节省在父组件中写的props值。

//父组件
<parent name="小明" age=5 sex="男" home="杭州" class="parent"></parent>

//子组件
<template>
  <grand-son :passAttr="$attrs"></grand-son>
</template>

props: {
    name: {
        type: String,
        default: ''
    }
}

$listener:

定义

包含了父作用域中的 (不含 .native 修饰器的) v-on 事件监听器。

实例说明
//父组件
<parent @change="onChange" @hide="OnHide"></parent>

//子组件
<script>
mounted () {
    console.log(this.$listener)
    // {change:f, hide:f}
}
</script>
使用场景

子组件中调用父组件的方法


provide 和 inject

provide:

是一个对象或返回一个对象的函数。该对象包含可注入其子孙的属性

inject:

是一个数组或者对象,包含祖先组件中注入的属性

实例说明:

//父组件:
<script>
provide: {
    name: '小明',
    sayName () {
        console.log('我是' + this.name)
    }
}
mounted() {
    this.name = '小花'
}
</script>


//子组件
<script>
inject:['name', 'sayName'],
mounted () {
    console.log(this.name); //小明
    this.sayName() //我是小明
}
</script>

可以看到在子组件中的 mounted 中打印出了 inject 中传递进来的 name 属性,并成功执行了 sayName 方法。而且 name 属性值还是'小明',并没有变成'小花',并不是响应式的。

注意点

  • provide 和 inject 成对出现使用
  • provide 注入的属性,在组件下的所有子孙组件中都能在 inject 中拿到
  • provide 和 inject 绑定并不是可响应的,这是和props的本质区别

$parent 和 \$children

定义:

已创建的实例的父实例和子实例中,子实例可以用 this.\$parent 访问父实例,子实例被推入父实例的 \$children 数组中,父实例可以用 this.$children 访问子实例。

实例说明

//父组件
mounted(){
console.log(this.$children)
//可以操作子组件的data,或者调用其methods中的方法
}

//子组件
mounted(){
console.log(this.$parent) //可以拿到父组件中的属性和方法
}

注意点

  • \$children和$parent不是响应式的
  • Vue官方有说明节制地使用 \$parent 和 $children - 它们的主要目的是作为访问组件的应急方法。

$refs

定义

一个对象,持有注册过 ref attribute 的所有 DOM 元素和组件实例。通过this.$refs.xxx 可以拿到 xxx 子组件中的data和methods。

实例说明
//父组件
<child ref="child"></child>

mounted(){
this.$refs.child.sayHello() // hello
}

//子组件
methods: {
    sayHello () {
        console.log('hello')
    }
}
注意点
  • this.$refs.xxx 必须在 xxx 组件渲染完成之后才能拿到,并不是所有生命周期中都能取到。
  • 通过 this.$refs.xxx 进行对子组件的属性操作并不是响应式的,因此避免在computed中进行使用。这是一个很好的应急手段,常用于以下两种使用场景

    1. 父组件直接调用子组件中的方法,节省不必要的代码重写
    2. 用来取代JQuery或者JS原生方法对dom元素的获取

vuex

不多说,强大的复杂单页面全局数据通信管理中心,供页面中的每个组件进行全局数据的操作,并响应式到各个组件中。如果不知道自行搜索。这里就简单说两个注意点

  • 注意vuex的使用场景,不要滥用,复杂的页面数据管理,如果只是简单的数据通信用props和$emit即可,不要为了用而用,适合的才是最好的
  • vuex中可以根据情况进行分模块管理,代码的可维护性会得到进一步提升

eventBus

定义

对于非父子组件来说,可以采用一个中间介质来作为一个中央事件总线,完成事件的触发和监听,并进行传值。

实例说明

第一步:创建事件总线:建立一个eventBus.js文件

//仅仅是为了创建一个vue实例
import Vue from 'vue'
export default new Vue()

第二步:在传值的源头组件中引进事件总线,进行事件的触发和传值

<template>
 <div class = "source">
   <button @click="emitEvent">
 </div>
</template>

<script>
import eventBus from './eventBus.js'
export default {
  methods: {
      emitEvent: funciton () {
        eventBus.$emit('sendVal',param)
      }
  }
}
</script>

第三步:在传值的目标组件中引进事件总线,进行事件的监听和传值的接收

<template>
 
</template>

<script>
import eventBus from './eventBus.js'
export default {
  data: function () {
      return {
          requireVal: ''
      }
  }
  mounted: function () {
    var that = this
    eventBus.$on('sendVal',function(val){
        that.requireVal = val
    }) 
  }
}
</script>

总结如下:

1.创建一个事件总线,例如demo中的eventBus,用它作为通信桥梁

2.在需要传值的组件中用bus.$emit触发一个自定义事件,并传递参数

3.在需要接收数据的组件中用bus.$on监听自定义事件,并在回调函数中处理传递过来的参数

注意点
  • eventBus 特别适合非跨多个层级组件之间的通信,解决了通过层层props传递的繁琐,也避免了使用vuex的相对臃肿。
  • 缺陷在于如果不加说明和规范管理,容易找不到事件的触发者和监听者。因此建议放在单独的js文件中创建好eventBus的vue实例,并在该文件中注释好事件的触发组件和监听组件。
查看原文

赞 0 收藏 0 评论 0

款冬 回答了问题 · 2020-03-29

如何处理http站点chrome80.0后samesite的问题?

我们也遇到了这个问题,说一下我这边的解决方式,给你参考一下:
首先线上环境 确实在setCookie的时候 通过设置 samesite: none 和 secure: true,并且运维侧配置https,解决了这个问题

开发环境,因为没有https的支持,所以通过关闭 chrome 的 samesite 默认设置,来实现http下的正常开发,具体的设置如下:
1.打开chrome设置:chrome://flags/#same-site-by-default-cookies
2.将 SameSite by default cookies 这一项设置修改成disabled,重启浏览器,就可以正常 在http下使用cookie了。

最后,我在考虑是否用token或者先存到localstorage里的方式来替代原来cookie的解决方式,不得不说chrome这一突然改动对开发来说太不友好了。

关注 3 回答 1

款冬 赞了文章 · 2020-03-04

Express 文档(错误处理)

错误处理

错误处理是指Express如何捕获和处理同步和异步发生的错误,Express附带一个默认的错误处理程序,因此你无需编写自己的错误处理程序即可开始使用。

捕捉错误

确保Express捕获运行路由处理程序和中间件时发生的所有错误非常重要。

路由处理程序和中间件内的同步代码中发生的错误不需要额外的工作,如果同步代码抛出错误,则Express将捕获并处理它,例如:

app.get("/", function (req, res) {
  throw new Error("BROKEN"); // Express will catch this on its own.
});

对于由路由处理程序和中间件调用的异步函数返回的错误,必须将它们传递给next()函数,Express将捕获并处理它们,例如:

app.get("/", function (req, res, next) {
  fs.readFile("/file-does-not-exist", function (err, data) {
    if (err) {
      next(err); // Pass errors to Express.
    }
    else {
      res.send(data);
    }
  });
});

如果将任何内容传递给next()函数(字符串'route'除外),则Express将当前请求视为错误,并将跳过任何剩余的非错误处理路由和中间件函数。

如果序列中的回调不提供数据,只提供错误,则可以按如下方式简化此代码:

app.get("/", [
  function (req, res, next) {
    fs.writeFile("/inaccessible-path", "data", next);
  },
  function (req, res) {
    res.send("OK");
  }
]);

在上面的示例中,next作为fs.writeFile的回调提供,调用时有或没有错误,如果没有错误,则执行第二个处理程序,否则Express会捕获并处理错误。

你必须捕获由路由处理程序或中间件调用的异步代码中发生的错误,并将它们传递给Express进行处理,例如:

app.get("/", function (req, res, next) {

  setTimeout(function () {
    try {
      throw new Error("BROKEN");
    }
    catch (err) {
      next(err);
    }
  }, 100);
});

上面的示例使用try...catch块来捕获异步代码中的错误并将它们传递给Express,如果省略try...catch块,Express将不会捕获错误,因为它不是同步处理程序代码的一部分。

使用promises可以避免try...catch块的开销或者使用返回promises的函数,例如:

app.get("/", function (req, res, next) {
  Promise.resolve().then(function () {
    throw new Error("BROKEN");
  }).catch(next); // Errors will be passed to Express.
});

由于promises会自动捕获同步错误和拒绝promises,你可以简单地提供next作为最终的catch处理程序,Express将捕获错误,因为catch处理程序被赋予错误作为第一个参数。

你还可以使用处理程序链来依赖同步错误捕获,通过将异步代码减少为一些简单的代码,例如:

app.get("/", [
  function (req, res, next) {
    fs.readFile("/maybe-valid-file", "utf8", function (err, data) {
        res.locals.data = data;
        next(err);
    });
  },
  function (req, res) {
    res.locals.data = res.locals.data.split(",")[1];
    res.send(res.locals.data);
  }
]);

上面的例子有一些来自readFile调用的简单语句,如果readFile导致错误,那么它将错误传递给Express,否则你将快速返回到链中下一个处理程序中的同步错误处理的世界。然后,上面的示例尝试处理数据,如果失败,则同步错误处理程序将捕获它,如果你在readFile回调中完成了此处理,则应用程序可能会退出,并且Express错误处理程序将无法运行。

无论使用哪种方法,如果要调用Express错误处理程序并使应用程序存活,你必须确保Express收到错误。

默认错误处理程序

Express附带了一个内置的错误处理程序,可以处理应用程序中可能遇到的任何错误,此默认错误处理中间件函数添加在中间件函数堆栈的末尾。

如果你将错误传递给next()并且你没有在自定义错误处理程序中处理它,它将由内置错误处理程序处理,错误将堆栈跟踪写入客户端,堆栈跟踪不包含在生产环境中。

将环境变量NODE_ENV设置为production,以在生产模式下运行应用程序。

如果在开始写入响应后调用next()并出现错误(例如,如果在将响应流式传输到客户端时遇到错误),则Express默认错误处理程序将关闭连接并使请求失败。

因此,当你添加自定义错误处理程序时,必须在headers已发送到客户端时委托给默认的Express错误处理程序:

function errorHandler (err, req, res, next) {
  if (res.headersSent) {
    return next(err)
  }
  res.status(500)
  res.render('error', { error: err })
}

请注意,如果你在你的代码调用next()出现错误多次,则会触发默认错误处理程序,即使自定义错误处理中间件已就绪也是如此。

编写错误处理程序

以与其他中间件函数相同的方式定义错误处理中间件函数,除了错误处理函数有四个参数而不是三个:(err, req, res, next),例如:

app.use(function (err, req, res, next) {
  console.error(err.stack)
  res.status(500).send('Something broke!')
})

你可以在其他app.use()和路由调用之后定义错误处理中间件,例如:

var bodyParser = require('body-parser')
var methodOverride = require('method-override')

app.use(bodyParser.urlencoded({
  extended: true
}))
app.use(bodyParser.json())
app.use(methodOverride())
app.use(function (err, req, res, next) {
  // logic
})

中间件函数内的响应可以是任何格式,例如HTML错误页面、简单消息或JSON字符串。

对于组织(和更高级别的框架)目的,你可以定义多个错误处理中间件函数,就像使用常规中间件函数一样,例如,为使用XHR和不使用XHR的请求定义错误处理程序:

var bodyParser = require('body-parser')
var methodOverride = require('method-override')

app.use(bodyParser.urlencoded({
  extended: true
}))
app.use(bodyParser.json())
app.use(methodOverride())
app.use(logErrors)
app.use(clientErrorHandler)
app.use(errorHandler)

在此示例中,通用logErrors可能会将请求和错误信息写入stderr,例如:

function logErrors (err, req, res, next) {
  console.error(err.stack)
  next(err)
}

同样在此示例中,clientErrorHandler定义如下,在这种情况下,错误会明确传递给下一个错误。

请注意,在错误处理函数中不调用“next”时,你负责编写(和结束)响应,否则这些请求将“挂起”,并且不符合垃圾回收的条件。

function clientErrorHandler (err, req, res, next) {
  if (req.xhr) {
    res.status(500).send({ error: 'Something failed!' })
  } else {
    next(err)
  }
}

实现“catch-all”的errorHandler函数,如下所示(例如):

function errorHandler (err, req, res, next) {
  res.status(500)
  res.render('error', { error: err })
}

如果你有一个具有多个回调函数的路由处理程序,则可以使用route参数跳转到下一个路由处理程序,例如:

app.get('/a_route_behind_paywall',
  function checkIfPaidSubscriber (req, res, next) {
    if (!req.user.hasPaid) {
      // continue handling this request
      next('route')
    }
    else{
      next();
    }
  }, function getPaidContent (req, res, next) {
    PaidContent.find(function (err, doc) {
      if (err) return next(err)
      res.json(doc)
    })
  })

在此示例中,将跳过getPaidContent处理程序,但app中的/a_route_behind_paywall中的任何剩余处理程序将继续执行。

next()next(err)的调用表明当前处理程序已完成并处于什么状态,next(err)将跳过链中的所有剩余处理程序,除了那些设置为处理上述错误的处理程序。

上一篇:使用模板引擎

下一篇:调试

查看原文

赞 1 收藏 0 评论 0

款冬 发布了文章 · 2020-03-03

vue数据渲染

前言

vue 是如何将编译器中的代码转换为页面真实元素的?这个过程涉及到模板编译成 AST 语法树,AST 语法树构建渲染函数,渲染函数生成虚拟 dom,虚拟 dom 编译成真实 dom 这四个过程。前两个过程在我们 vue 源码解读系列文章的上一期已经介绍过了,所以本文会接着上一篇文章继续往下解读,着重分析后两个过程。

整体流程

解读代码之前,先看一张 vue 编译和渲染的整体流程图:
屏幕快照 2020-01-04 下午9.29.17.png

vue 会把用户写的代码中的 <template></template> 标签中的代码解析成 AST 语法树,再将处理后的 AST 生成相应的render函数,render 函数执行后会得到与模板代码对应的虚拟 dom,最后通过虚拟 dom 中新旧 vnode 节点的对比和更新,渲染得到最终的真实 dom。 有了这个整体的概念我们再来结合源码分析具体的数据渲染过程。

从vm.$mount开始

vue 中是通过 $mount 实例方法去挂载 vm 的,数据渲染的过程就发生在 vm.$mount 阶段。在这个方法中,最终会调用 mountComponent 方法来完成数据的渲染。我们结合源码看一下其中的几行关键代码:

 updateComponent = () => {
      vm._update(vm._render(), hydrating) // 生成虚拟dom,并更新真实dom
    }

这是在 mountComponent 方法的内部,会定义一个 updateComponent 方法,在这个方法中 vue 会通过 vm._render() 函数生成虚拟 dom,并将生成的 vnode 作为第一个参数传入 vm._update() 函数中进而完成虚拟 dom 到真实 dom 的渲染。第二个参数 hydrating 是跟服务端渲染相关的,在浏览器中不需要关心。这个函数最后会作为参数传入到 vue 的 watch 实例中作为 getter 函数,用于在数据更新时触发依赖收集,完成数据响应式的实现。这个过程不在本文的介绍范围内,在这里只要明白,当后续 vue 中的 data 数据变化时,都会触发 updateComponent 方法,完成页面数据的渲染更新。具体的关键代码如下:

  new Watcher(vm, updateComponent, noop, {
    before () {
      if (vm._isMounted && !vm._isDestroyed) {
        // 触发beforeUpdate钩子
        callHook(vm, 'beforeUpdate')
      }
    }
  }, true /* isRenderWatcher */)
  hydrating = false

  // manually mounted instance, call mounted on self
  // mounted is called for render-created child components in its inserted hook
  if (vm.$vnode == null) {
    vm._isMounted = true
    // 触发mounted钩子
    callHook(vm, 'mounted')
  }
  return vm
}

代码中还有一点需要注意的是,在代码结束处,会做一个判断,当 vm 挂载成功后,会调用 vue 的 mounted 生命周期钩子函数。这也就是为什么我们在 mounted 钩子中执行代码时,vm 已经挂载完成的原因。

vm._render()

接下来具体分析 vue 生成虚拟 dom 的过程。前面说了这一过程是调用vm._render()方法来完成的,该方法的核心逻辑是调用vm.$createElement方法生成vnode,代码如下:

vnode = render.call(vm._renderProxy, vm.$createElement)

其中vm.renderProxy是个代理,代理vm,做一些错误处理,vm.$createElement 是创建vnode的真正方法,该方法的定义如下:

vm.$createElement = (a, b, c, d) => createElement(vm, a, b, c, d, true)

可见最终调用的是createElement方法来实现生成vnode的逻辑。在进一步介绍createElement方法之前,我们先理清楚两个个关键点,1.render的函数来源,2.vnode到底是什么

render方法的来源

在 vue 内部其实定义了两种 render 方法的来源,一种是如果用户手写了 render 方法,那么 vue 会调用这个用户自己写的 render 方法,即下面代码中的 vm.$createElement;另外一种是用户没有手写 render 方法,那么vue内部会把 template 编译成 render 方法,即下面代码中的 vm._c。不过这两个 render 方法最终都会调用createElement方法来生成虚拟dom

// bind the createElement fn to this instance
  // so that we get proper render context inside it.
  // args order: tag, data, children, normalizationType, alwaysNormalize
  // internal version is used by render functions compiled from templates
  vm._c = (a, b, c, d) => createElement(vm, a, b, c, d, false)
  // normalization is always applied for the public version, used in
  // user-written render functions.
  vm.$createElement = (a, b, c, d) => createElement(vm, a, b, c, d, true)

vnode类

vnode 就是用一个原生的 js 对象去描述 dom 节点的类。因为浏览器操作dom的成本是很高的,所以利用 vnode 生成虚拟 dom 比创建一个真实 dom 的代价要小很多。vnode 类的定义如下:

export default class VNode {
  tag: string | void; // 当前节点的标签名
  data: VNodeData | void; // 当前节点对应的对象
  children: ?Array<VNode>; // 当前节点的子节点
  text: string | void; // 当前节点的文本
  elm: Node | void; // 当前虚拟节点对应的真实dom节点
  ....
  
  /*创建一个空VNode节点*/
  export const createEmptyVNode = (text: string = '') => {
    const node = new VNode()
    node.text = text
    node.isComment = true
    return node
  }
  /*创建一个文本节点*/
  export function createTextVNode (val: string | number) {
    return new VNode(undefined, undefined, undefined, String(val))
  }
   ....

可以看到 vnode 类中仿照真实 dom 定义了很多节点属性和一系列生成各类节点的方法。通过对这些属性和方法的操作来达到模仿真实 dom 变化的目的。

createElement

有了前面两点的知识储备,接下来回到 createElement 生成虚拟 dom 的分析。createElement 方法中的代码很多,这里只介绍跟生成虚拟 dom 相关的代码。该方法总体来说就是创建并返回一个 vnode 节点。 在这个过程中可以拆分成三件事情:1.子节点的规范化处理; 2.根据不同的情形创建不同的 vnode 节点类型;3.vnode 创建后的处理。下面开始分析这3个步骤:

子节点的规范化处理

  if (normalizationType === ALWAYS_NORMALIZE) {
    children = normalizeChildren(children)
  } else if (normalizationType === SIMPLE_NORMALIZE) {
    children = simpleNormalizeChildren(children)
  }

为什么会有这个过程,是因为传入的参数中的子节点是 any 类型,而 vue 最终生成的虚拟 dom 实际上是一个树状结构,每一个 vnode 可能会有若干个子节点,这些子节点应该也是 vnode 类型。所以需要对子节点处理,将子节点统一处理成一个 vnode 类型的数组。同时还需要根据 render 函数的来源不同,对子节点的数据结构进行相应处理。

创建vnode节点

这部分逻辑是对tag标签在不同情况下的处理,梳理一下具体的判断case如下:

  1. 如果传入的 tag 标签是字符串,则进一步进入下列第 2 点和第 3 点判断,如果不是字符串则创建一个组件类型 vnode 节点。
  2. 如果是内置的标签,则创建一个相应的内置标签 vnode 节点。
  3. 如果是一个组件标签,则创建一个组件类型 vnode 节点。
  4. 其他情况下,则创建一个命名空间未定义的 vnode 节点。
  let vnode, ns
  if (typeof tag === 'string') {
    let Ctor
    // 获取tag的名字空间
    ns = (context.$vnode && context.$vnode.ns) || config.getTagNamespace(tag)

    // 判断是否是内置的标签,如果是内置的标签则创建一个相应节点
    if (config.isReservedTag(tag)) {
      // platform built-in elements
      if (process.env.NODE_ENV !== 'production' && isDef(data) && isDef(data.nativeOn)) {
        warn(
          `The .native modifier for v-on is only valid on components but it was used on <${tag}>.`,
          context
        )
      }
      vnode = new VNode(
        config.parsePlatformTagName(tag), data, children,
        undefined, undefined, context
      )
      // 如果是组件,则创建一个组件类型节点
      // 从vm实例的option的components中寻找该tag,存在则就是一个组件,创建相应节点,Ctor为组件的构造类
    } else if ((!data || !data.pre) && isDef(Ctor = resolveAsset(context.$options, 'components', tag))) {
      // component
      vnode = createComponent(Ctor, data, context, children, tag)
    } else {
      // unknown or unlisted namespaced elements
      // check at runtime because it may get assigned a namespace when its
      // parent normalizes children

      //其他情况,在运行时检查,因为父组件可能在序列化子组件的时候分配一个名字空间
      vnode = new VNode(
        tag, data, children,
        undefined, undefined, context
      )
    }
  } else {
    // direct component options / constructor
    // tag不是字符串的时候则是组件的构造类,创建一个组件节点
    vnode = createComponent(tag, data, context, children)
  }
vnode创建后的处理

这部分同样也是一些 if/else 分情况的处理逻辑:

  1. 如果 vnode 成功创建,且是一个数组类型,则返回创建好的 vnode 节点
  2. 如果 vnode 成功创建,且有命名空间,则递归所有子节点应用该命名空间
  3. 如果 vnode 没有成功创建则创建并返回一个空的 vnode 节点
  if (Array.isArray(vnode)) {
    // 如果vnode成功创建,且是一个数组类型,则返回创建好的vnode节点
    return vnode
  } else if (isDef(vnode)) {
    // 如果vnode成功创建,且名字空间,则递归所有子节点应用该名字空间
    if (isDef(ns)) applyNS(vnode, ns)
    if (isDef(data)) registerDeepBindings(data)
    return vnode
  } else {
    // 如果vnode没有成功创建则创建空节点
    return createEmptyVNode()
  }

vm._update()

vm._update() 做的事情就是把 vm._render() 生成的虚拟 dom 渲染成真实 dom。_update() 方法内部会调用 vm.__patch__ 方法来完成视图更新,最终调用的是 createPatchFunction 方法,该方法的代码量和逻辑都非常多,它定义在 src/core/vdom/patch.js 文件中。下面介绍下具体的 patch 流程和流程中用到的重点方法:

重点方法

  1. createElm:该方法会根据传入的虚拟 dom 节点创建真实的 dom 并插入到它的父节点中
  2. sameVnode:判断新旧节点是否是同一节点。
  3. patchVnode:当新旧节点是相同节点时,调用该方法直接修改节点,在这个过程中,会利用 diff 算法,循环进行子节点的的比较,进而进行相应的节点复用或者替换。
  4. updateChildren方法:diff 算法的具体实现过程

patch流程

第一步:

判断旧节点是否存在,如果不存在就调用 createElm() 创建一个新的 dom 节点,否则进入第二步判断。

 if (isUndef(oldVnode)) {
      // empty mount (likely as component), create new root element
      isInitialPatch = true
      createElm(vnode, insertedVnodeQueue)
 }
第二步:

通过 sameVnode() 判断新旧节点是否是同一节点,如果是同一个节点则调用 patchVnode() 直接修改现有的节点,否则进入第三步判断

const isRealElement = isDef(oldVnode.nodeType)
if (!isRealElement && sameVnode(oldVnode, vnode)) {
    // patch existing root node
    /*是同一个节点的时候直接修改现有的节点*/
    patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
}
第三步:

如果新旧节点不是同一节点,则调用 createElm()创建新的dom,并更新父节点的占位符,同时移除旧节点。

else {
    ....
    createElm(
          vnode,
          insertedVnodeQueue,
          // extremely rare edge case: do not insert if old element is in a
          // leaving transition. Only happens when combining transition +
          // keep-alive + HOCs. (#4590)
          oldElm._leaveCb ? null : parentElm,
          nodeOps.nextSibling(oldElm)
    )
     // update parent placeholder node element, recursively
        /*更新父的占位符节点*/
        if (isDef(vnode.parent)) {
          let ancestor = vnode.parent
          const patchable = isPatchable(vnode)
          while (ancestor) {
            for (let i = 0; i < cbs.destroy.length; ++i) {
              cbs.destroy[i](ancestor)  /*调用destroy回调*/
            }
            ancestor.elm = vnode.elm
            if (patchable) {
              for (let i = 0; i < cbs.create.length; ++i) {
                cbs.create[i](emptyNode, ancestor)  /*调用create回调*/
              }
              // #6513
              // invoke insert hooks that may have been merged by create hooks.
              // e.g. for directives that uses the "inserted" hook.
              const insert = ancestor.data.hook.insert
              if (insert.merged) {
                // start at index 1 to avoid re-invoking component mounted hook
                for (let i = 1; i < insert.fns.length; i++) {
                  insert.fns[i]()
                }
              }
            } else {
              registerRef(ancestor)
            }
            ancestor = ancestor.parent
          }
        }
        // destroy old node
        if (isDef(parentElm)) {
          removeVnodes([oldVnode], 0, 0) /* 删除旧节点 */
        } else if (isDef(oldVnode.tag)) {
          invokeDestroyHook(oldVnode) /* 调用destroy钩子 */
        }
}
第四步:

返回 vnode.elm,即最后生成的虚拟 dom 对应的真实 dom,将 vm.$el 赋值为这个 dom 节点,完成挂载。

其中重点的过程在第二步和第三步中,特别是 diff 算法对新旧节点的比较和更新很有意思,diff 算法在另外一篇文章来详细介绍 Vue中的diff算法

其他注意点

sameVnode的实际应用

在patch的过程中,如果两个节点被判断为同一节点,会进行复用。这里的判断标准是

1.key相同

2.tag(当前节点的标签名)相同

3.isComment(是否为注释节点)相同

4.data的属性相同

平时写 vue 时会遇到一个组件中用到了 A 和 B 两个相同的子组件,可以来回切换。有时候会出现改变了 A 组件中的值,切到 B 组件中,发现 B 组件的值也被改变成和 A 组件一样了。这就是因为 vue 在 patch 的过程中,判断出了 A 和 B 是 sameVnode,直接进行复用引起的。根据源码的解读,可以很容易地解决这个问题,就是给 A 和 B 组件分别加上不同的 key 值,避免 A 和 B 被判断为同一组件。

虚拟DOM如何映射到真实的DOM节点

vue 为平台做了一层适配层,浏览器平台的代码在 /platforms/web/runtime/node-ops.js。不同平台之间通过适配层对外提供相同的接口,虚拟 dom 映射转换真实 dom 节点的时候,只需要调用这些适配层的接口即可,不需要关心内部的实现。

最后

通过上述的源码和实例的分析,我们完成了 Vue 中 数据渲染 的完整解读。如果想要了解更多的 Vue 源码。欢迎进入我们的github进行查看,里面有Vue源码分析另外几篇文章,另外对 Vue 工程的每一行源码都做了注释,方便大家的理解。~~~~

查看原文

赞 6 收藏 4 评论 1

认证与成就

  • 获得 121 次点赞
  • 获得 27 枚徽章 获得 2 枚金徽章, 获得 7 枚银徽章, 获得 18 枚铜徽章

擅长技能
编辑

(゚∀゚ )
暂时没有

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2017-02-26
个人主页被 1.7k 人浏览