款冬

款冬 查看完整档案

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

前端小小弄潮儿, 内推杭州一二线互联网大厂前端开发、前端实习生~

个人动态

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

关注 111

款冬 发布了文章 · 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

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

Vue中的diff算法

前言

Vue 数据渲染中最核心的的部分就是 diff算法 的应用,本文从源码入手,结合实例,一步步解析 diff 算法的整个流程。

diff算法简介

diff算法是一种通过同层的树节点进行比较的高效算法,避免了对树进行逐层搜索遍历,所以时间复杂度只有 O(n)。diff算法的在很多场景下都有应用,例如在 vue 虚拟 dom 渲染成真实 dom 的新旧 VNode 节点比较更新时,就用到了该算法。diff算法有两个比较显著的特点:

1、比较只会在同层级进行, 不会跨层级比较。
image
2、在diff比较的过程中,循环从两边向中间收拢
image

diff流程

本着对 diff 过程的认识和 vue 源码的学习,我们通过 vue 源码的解读和实例分析来理清楚 diff 算法的整个流程,下面把整个 diff 流程拆成三步来具体分析:

第一步

vue 的虚拟 dom 渲染真实 dom 的过程中首先会对新老 VNode 的开始和结束位置进行标记:oldStartIdx、oldEndIdx、newStartIdx、newEndIdx。

let oldStartIdx = 0 // 旧节点开始下标
let newStartIdx = 0 // 新节点开始下标
let oldEndIdx = oldCh.length - 1 // 旧节点结束下标
let oldStartVnode = oldCh[0]  // 旧节点开始vnode
let oldEndVnode = oldCh[oldEndIdx] // 旧节点结束vnode
let newEndIdx = newCh.length - 1 // 新节点结束下标
let newStartVnode = newCh[0] // 新节点开始vnode
let newEndVnode = newCh[newEndIdx] // 新节点结束vnode

经过第一步之后,我们初始的新旧 VNode 节点如下图所示:
image

第二步

标记好节点位置之后,就开始进入到的 while 循环处理中,这里是 diff 算法的核心流程,分情况进行了新老节点的比较并移动对应的 VNode 节点。while 循环的退出条件是直到老节点或者新节点的开始位置大于结束位置。

while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    ....//处理逻辑
}

接下来具体介绍 while 循环中的处理逻辑, 循环过程中首先对新老 VNode 节点的头尾进行比较,寻找相同节点,如果有相同节点满足 sameVnode(可以复用的相同节点) 则直接进行 patchVnode (该方法进行节点复用处理),并且根据具体情形,移动新老节点的 VNode 索引,以便进入下一次循环处理,一共有 2 \* 2 = 4 种情形。下面根据代码展开分析:

情形一:当新老 VNode 节点的 start 满足sameVnode 时,直接 patchVnode 即可,同时新老 VNode 节点的开始索引都加1。
    if (sameVnode(oldStartVnode, newStartVnode)) {
        patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
        oldStartVnode = oldCh[++oldStartIdx]
        newStartVnode = newCh[++newStartIdx]
     }
情形二:当新老 VNode 节点的 end 满足 sameVnode 时,同样直接 patchVnode 即可,同时新老 VNode 节点的结束索引都减1。
else if (sameVnode(oldEndVnode, newEndVnode)) {
        patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
        oldEndVnode = oldCh[--oldEndIdx]
        newEndVnode = newCh[--newEndIdx]
      }
情形三:当老 VNode 节点的 start 和新 VNode 节点的 end 满足 sameVnode 时,这说明这次数据更新后 oldStartVnode 已经跑到了 oldEndVnode 后面去了。这时候在 patchVnode 后,还需要将当前真实 dom 节点移动到 oldEndVnode 的后面,同时老 VNode 节点开始索引加1,新 VNode 节点的结束索引减1。
else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
        patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
        canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
        oldStartVnode = oldCh[++oldStartIdx]
        newEndVnode = newCh[--newEndIdx]
      }
情形四:当老 VNode 节点的 end 和新 VNode 节点的 start 满足 sameVnode 时,这说明这次数据更新后 oldEndVnode 跑到了 oldStartVnode 的前面去了。这时候在 patchVnode 后,还需要将当前真实 dom 节点移动到 oldStartVnode 的前面,同时老 VNode 节点结束索引减1,新 VNode 节点的开始索引加1。
else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
        patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
        canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
        oldEndVnode = oldCh[--oldEndIdx]
        newStartVnode = newCh[++newStartIdx]
      }

如果都不满足以上四种情形,那说明没有相同的节点可以复用,于是则通过查找事先建立好的以旧的 VNode 为 key 值,对应 index 序列为 value 值的哈希表。从这个哈希表中找到与 newStartVnode 一致 key 的旧的 VNode 节点,如果两者满足 sameVnode 的条件,在进行 patchVnode 的同时会将这个真实 dom 移动到 oldStartVnode 对应的真实 dom 的前面;如果没有找到,则说明当前索引下的新的 VNode 节点在旧的 VNode 队列中不存在,无法进行节点的复用,那么就只能调用 createElm 创建一个新的 dom 节点放到当前 newStartIdx 的位置。

else {// 没有找到相同的可以复用的节点,则新建节点处理
        /* 生成一个key与旧VNode的key对应的哈希表(只有第一次进来undefined的时候会生成,也为后面检测重复的key值做铺垫) 比如childre是这样的 [{xx: xx, key: 'key0'}, {xx: xx, key: 'key1'}, {xx: xx, key: 'key2'}] beginIdx = 0 endIdx = 2 结果生成{key0: 0, key1: 1, key2: 2} */
        if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
        /*如果newStartVnode新的VNode节点存在key并且这个key在oldVnode中能找到则返回这个节点的idxInOld(即第几个节点,下标)*/
        idxInOld = isDef(newStartVnode.key)
          ? oldKeyToIdx[newStartVnode.key]
          : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
        if (isUndef(idxInOld)) { // New element
          /*newStartVnode没有key或者是该key没有在老节点中找到则创建一个新的节点*/
          createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
        } else {
          /*获取同key的老节点*/
          vnodeToMove = oldCh[idxInOld]
          if (sameVnode(vnodeToMove, newStartVnode)) {
            /*如果新VNode与得到的有相同key的节点是同一个VNode则进行patchVnode*/
            patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
            //因为已经patchVnode进去了,所以将这个老节点赋值undefined
            oldCh[idxInOld] = undefined
            /*当有标识位canMove实可以直接插入oldStartVnode对应的真实Dom节点前面*/
            canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
          } else {
            // same key but different element. treat as new element
            /*当新的VNode与找到的同样key的VNode不是sameVNode的时候(比如说tag不一样或者是有不一样type的input标签),创建一个新的节点*/
            createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
          }
        }
        newStartVnode = newCh[++newStartIdx]
      }

再来看我们的实例,第一次循环后,找到了旧节点的末尾和新节点的开头(都是D)相同,于是直接复用 D 节点作为 diff 后创建的第一个真实节点。同时旧节点的 endIndex 移动到了 C,新节点的 startIndex 移动到了 C。
image

紧接着开始第二次循环,第二次循环后,同样是旧节点的末尾和新节点的开头(都是C)相同,同理,diff 后创建了 C 的真实节点插入到第一次创建的 B 节点后面。同时旧节点的 endIndex 移动到了 B,新节点的 startIndex 移动到了 E。
image

接下来第三次循环中,发现 patchVnode 的4种情形都不符合,于是在旧节点队列中查找当前的新节点 E,结果发现没有找到,这时候只能直接创建新的真实节点 E,插入到第二次创建的 C 节点之后。同时新节点的 startIndex 移动到了 A。旧节点的 startIndex 和 endIndex 都保持不动。![]
image

第四次循环中,发现了新旧节点的开头(都是A)相同,于是 diff 后创建了 A 的真实节点,插入到前一次创建的 E 节点后面。同时旧节点的 startIndex 移动到了B,新节点的startIndex 移动到了B。
image

第五次循环中,情形同第四次循环一样,因此 diff 后创建了 B 真实节点 插入到前一次创建的 A 节点后面。同时旧节点的 startIndex 移动到了C,新节点的 startIndex 移动到了F。
image

这时候发现新节点的 startIndex 已经大于 endIndex 了。不再满足循环的条件了。因此结束循环,接下来走后面的逻辑。

第三步

当 while 循环结束后,根据新老节点的数目不同,做相应的节点添加或者删除。若新节点数目大于老节点则需要把多出来的节点创建出来加入到真实 dom 中,反之若老节点数目大于新节点则需要把多出来的老节点从真实 dom 中删除。至此整个 diff 过程就已经全部完成了。

 if (oldStartIdx > oldEndIdx) {
      /*全部比较完成以后,发现oldStartIdx > oldEndIdx的话,说明老节点已经遍历完了,新节点比老节点多, 所以这时候多出来的新节点需要一个一个创建出来加入到真实Dom中*/
      refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
      addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue) //创建 newStartIdx - newEndIdx 之间的所有节点
    } else if (newStartIdx > newEndIdx) {
      /*如果全部比较完成以后发现newStartIdx > newEndIdx,则说明新节点已经遍历完了,老节点多于新节点,这个时候需要将多余的老节点从真实Dom中移除*/
      removeVnodes(oldCh, oldStartIdx, oldEndIdx) //移除 oldStartIdx - oldEndIdx 之间的所有节点
    }

再回过头看我们的实例,新节点的数目大于旧节点,需要创建 newStartIdx 和 newEndIdx 之间的所有节点。在我们的实例中就是节点 F,因此直接创建 F 节点对应的真实节点放到 B 节点后面即可。
image

最后

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

查看原文

赞 16 收藏 10 评论 6

款冬 收藏了文章 · 2020-01-10

收藏的关于Vue技巧or实践的文章

刚开始学vue的时候用的是全家桶里的vue-resource,后来发现axios延展性更强。这篇文章简单梳理了axios的API,简洁清晰明了。

了解完axios后下一步就是封装成适应项目的方法,比如拦截请求、状态码相应跳转提示、接口模块化等。个人而言由于项目较复杂,所以用了vuex,接口放在store采用moduls分模块管理。顺便拓展一下:axios请求超时,设置重新请求的完美解决方法

教你如何写一个自定义组件,此处用到了Vue.extend()。没有很惊艳,但是能让你思路眼前一亮or瞬间醍醐灌顶,就像打通任督二脉,都是好文章。

vue-element-admin的作者,vue后台的系列文章有很多值得参考的地方,尤其是统筹和模块管理方面。

五个有用的小技巧,原文在YouTube,评论区有人贴出,可看原视频。

文章繁复,值得参考的地方不多,适合新手看。对于实践过几个项目的人来说,基本api已经熟能生巧。

祖孙、父子、兄弟、隔代信息传递,比较常用的都归纳了。大项目用vuex方便很多,小项目附上一个EventBus的介绍:vue篇之事件总线(EventBus)

3.0用的是TypeScript,当然要跟紧发展的脚步。这篇写的比较基础,对于我这种新手容易理解,学TypeScript建议还是回归到官方文档,能发现自己的宝藏。

关于vuex的用法官网已经清晰,加上实践出真知,所以收藏里没有关于vuex的文章,在掘金找了个简单易懂的。emmm看啥时候写篇自己的总结。


来几篇面试题,最考验基础了


平日没有收藏的习惯(大概知道自己收藏了猴年马月才会看),以上能在收藏找到都是对自身有启发的key。

更多分享内容可以看我的博客

查看原文

款冬 收藏了文章 · 2019-02-18

webpack-loader 之 url-loader

url-loader vs file-loader

既生瑜,何生亮?

人家当然不是以卖萌为生的,卖萌不可耻,但是url-loader是有它的用处的。url-loader对未设置或者小于limit设置的图片进行转换,以base64的格式被img的src所使用;而对于大于limit byte的图片用file-loader进行解析。

url-loader-code-part

file-loader-code-part

over~~,稍微瞄一下代码,是不是很简单,自己都在偷偷笑了,哇咔咔

不过虽然寥寥几行,还是有不懂得腻:

  • 模块入参content是个Buffer类型?

    • Buffer是啥?是node处理二进制数据的接口哦,toString()方法可以帮你把二进制转化成base64格式,Buffer可以吃么,看我
    • 为啥是Buffer类型?这个webpack处理有关系喽~,默认情况下,资源文件会被转换成utf-8字符串传给loader处理。这个代码里我们看到它设置了raw,翻译一下:loader这样就可以接受原始的Buffer了。如何写一个Loader?难道你不想知道么?
  • file-loader最后一步干了啥?

    • emitFile(name: string, content: Buffer|String, sourceMap: {...}) 这是 webpack loader context提供的内部方法,根据路径和内容生成一个新的图片,供html以绝对路径的方式进行请求和使用。
    • 在file-loader的option配置中可以将emitFile设置为false,文件不再被重新创建,多用于在服务端模块的使用,图片直接使用服务端的即可。

url-loader 配置

工欲善其事,必先利其器。

看一下官网webpack给出的url-loader的配置参数吧。
戳我

配置名称类型默认值含义
limit{Number}undefined转化为data-url内联使用的文件带下阈值
mimetype{String}文件扩展名文件的mimetype类型(默认使用文件扩展类型)
fallback{String}file-loader在文件大于limit时,交于处理的加载器

在webpack中配置可如下:
url-loader配置

编码base64的姿势是什么呢?

知其然之其所以然。

通过url-loader将小图片转换为base64后,面对一长串的它,你是否困惑了呢?它是谁?它又是怎么出现的?

带着这个问题,我们顺路看一下这个小东西的原理吧:

[名字的由来]:通过下面64个可打印的字符来表示二进制数据
base64对照表

[它的原理]:64=2的6次方,因此每6位都可以用一个base64字符表示。而每1个
字节是8位,那么3个字节 = 3 * 8 = 24bit = 6 * 4 = 4个base64字符(这样看来,用base64
表示二进制,比原来二进制表示多了1/3倍的字节)。

[它的步骤]

  • 按照字符长度,每3个8bit的字符为一组(不过3的倍数的字符组,用“=”进行填充)
  • 根据分组,将每个字符用ASCII进行编码,并将其转换为8bit的二进制,从而得到一组3*8=24bit的字节
    (如果不够24bit,用0进行填充)
  • 将24bit划为4个6bit,转换成10进制值,在base64表中查找对应的符号,转换后的字符拼接起来就是最后的结果了.

看一下这个小例子,练练手吧~
“Girl” => “JGlybA==”
小栗子

用了它,会变得美好一点么?

说了这么多原理,那用了它,对我们有什么实际的好处呢?这个分情况讨论呢。

首先我们要了解一下它的优缺点,这样就好判断使用场景了。

优点 vs 缺点

  • 对于比较小的图片,使用base64编码,可以减少一次图片的网络请求;那么对于比较大的图片,使用base64就不适合了,编码会和html混在一起,一方面可读性差,另一方面加大了html页面的大小,反而加大了下载页面的大小,得不偿失了呢,因此设置一个合理的limit是非常有必要的;
  • 另一方面,base64编码的图片不能像正常的图片可以进行缓存,因此写在css里面可以让浏览器对css文件进行缓存也不错哦;
查看原文

款冬 关注了专栏 · 2019-02-16

边城客栈

全栈技术专栏

关注 3389

款冬 关注了专栏 · 2019-02-16

不想当架构的前端不是一个好厨子

好的前端是慢慢积累出来

关注 99

款冬 收藏了文章 · 2019-01-14

node服务端渲染(完整demo)

简介

nodejs搭建多页面服务端渲染
  • 技术点

    1. koa 搭建服务
    2. koa-router 创建页面路由
    3. nunjucks 模板引擎组合html
    4. webpack打包多页面
    5. node端异步请求
    6. 服务端日志打印
    项目源码 git clone https://gitee.com/wjj0720/nod...
  • 运行

    • npm i
    • npm start

一、 现代服务端渲染的由来

服务端渲染概念: 是指,浏览器向服务器发出请求页面,服务端将准备好的模板和数据组装成完整的HTML返回给浏览器展示
  • 1、前端后端分离

    早在七八年前,几乎所有网站都使用 ASP、Java、PHP做后端渲染,随着网络的加快,客户端性能提高以及js本身的性能提高,我们开始往客户端增加更多的功能逻辑和交互,前端不再是简单的html+css更多的是交互,前端页在这是从后端分离出来「前后端正式分家」
  • 2、客户端渲染

    随着ajax技术的普及以及前端框架的崛起(jq、Angular、React、Vue) 框架的崛起,开始转向了前端渲染,使用 JS 来渲染页面大部分内容达到局部刷新的作用
    • 优势

      • 局部刷新,用户体验优
      • 富交互
      • 节约服务器成本
    • 缺点

      • 不利于SEO(爬虫无法爬取ajax)请求回来的数据
      • 受浏览器性能限制、增加手机端的耗电
      • 首屏渲染需要等js运行才能展示数据
  • 3、现在服务端渲染

    为了解决上面客户端渲染的缺点,然前后端分离后必不能合,如果要把前后端部门合并,拆掉的肯定是前端部门
    • 现在服务端渲染的特点

      • 前端开发人员编写html+css模板
      • node中间服务负责前端模板和后台数据的组合
      • 数据依然由java等前服务端语言提供
    • 优势

      • 前后端分工明确
      • SEO问题解决
  • 4、前、后端渲染相关讨论参考


二、 项目开始

确保你安装node

第一步 让服务跑起来

目标: 创建node服务,通过浏览器访问,返回'hello node!'(html页面其实就是一串字符串)
  /** 创建项目目录结构如下 */
    │─ package-lock.json
    │─ package.json
    │─ README.md
    ├─bin
      │─ www.js

  // 1. 安装依赖 npm i koa 
  // 2. 修改package.json文件中 scripts 属性如下
    "scripts": {
      "start": "node bin/www.js"
    }

  // 3. www.js写入如下代码
    const Koa = require('koa');
    let app = new Koa();
    app.use(ctx => {
      ctx.body = 'hello node!'
    });
    app.listen(3000, () => {
      console.log('服务器启动 http://127.0.0.1:3000');
    });

  // 4 npm start 浏览器访问 http://127.0.0.1:3000 查看效果

第二步 路由的使用

目标:使用koa-router根据不同url返回不同页面内容
  • 依赖 npm i koa-router
    koa-router 更多细节 请至npm查看

      /** 新增routers文件夹   目录结构如下 
        │─.gitignore
        │─package.json
        │─README.md
        ├─bin
        │   │─www.js
        ├─node_modules
        └─routers
            │─home.js
            │─index.js
            │─user.js 
      */
      //项目中应按照模块对路由进行划分,示例简单将路由划分为首页(/)和用户页(/user) 在index中将路由集中管理导, 出并在app实例后挂载到app上
      /** router/home.js 文件 */
      // 引包
      const homeRouter = require('koa-router')()
      //创建路由规则
      homeRouter.get(['/', '/index.html', '/index', '/home.html', '/home'], (ctx, next) => {
        ctx.body = 'home'
      });
      // 导出路由备用
      module.exports = homeRouter
    
      /** router/user.js 文件 */
      const userRouter = require('koa-router')()
      userRouter.get('/user', (ctx, next) => {
        ctx.body = 'user'
      });
      module.exports = userRouter
    
      /** router/index.js 文件 */
      // 路由集中点
      const routers = [
        require('./home.js'),
        require('./user.js')
      ]
      // 简单封装 
      module.exports = function (app) {
        routers.forEach(router => {
          app.use(router.routes())
        })
        return routers[0]
      }
  /** www.js 文件改写 */
  // 引入koa
  const Koa = require('koa')
  const Routers = require('../routers/index.js')
  // 实例化koa对象
  let app = new Koa()

  // 挂载路由
  app.use((new Routers(app)).allowedMethods())

  // 监听3000端口
  app.listen(3000, () => {
    console.log('服务器启动 http://127.0.0.1:3000')
  })

第三步 加入模板

目标:
1.使用nunjucks解析html模板返回页面
2.了解koa中间件的使用
  • 依赖 npm i nunjucks

nunjucks中文文档

  /*
    *我向项目目录下加入两个准备好的html文件 目录结构如下
    │─.gitignore
    │─package.json
    │─README.md
    ├─bin
    │   │─www.js
    │─middlewares  //新增中间件目录  
    │   ├─nunjucksMiddleware.js  //nunjucks模板中间件
    ├─node_modules
    │─routers
    │   │─home.js
    │   │─index.js
    │   │─user.js 
    │─views  //新增目录 作为视图层
        ├─home
        │   ├─home.html 
        ├─user
            ├─user.html
   */
  /* nunjucksMiddleware.js 中间件的编写 
    *什么是中间件: 中间件就是在程序执行过程中增加辅助功能
    *nunjucksMiddleware作用: 给请求上下文加上render方法 将来在路由中使用 
  */
  const nunjucks = require('nunjucks')
  const path = require('path')
  const moment = require('moment')
  let nunjucksEVN = new nunjucks.Environment(new nunjucks.FileSystemLoader('views'))
  // 为nkj加入一个过滤器
  nunjucksEVN.addFilter('timeFormate',  (time, formate) => moment(time).format( formate || 'YYYY-MM-DD HH:mm:ss'))

  // 判断文件是否有html后缀
  let isHtmlReg = /\.html$/
  let resolvePath = (params = {}, filePath) => {
    filePath = isHtmlReg.test(filePath) ? filePath : filePath + (params.suffix || '.html')
    return path.resolve(params.path || '', filePath)
  }

  /** 
  * @description nunjucks中间件 添加render到请求上下文
  * @param params {}
  */
  module.exports = (params) => {
    return (ctx, next) => {
      ctx.render = (filePath, renderData = {}) => {
        ctx.type = 'text/html'
        ctx.body = nunjucksEVN.render(resolvePath(params, filePath), Object.assign({}, ctx.state, renderData))
      }
      // 中间件本身执行完成 需要调用next去执行下一步计划
      return next()
    }
  }
  /* 中间件挂载 www.js中增加部分代码 */

  // 头部引入文件 
  const nunjucksMiddleware = require('../middlewares/nunjucksMiddleware.js')
  //在路由之前调用 因为我们的中间件是在路由中使用的 故应该在路由前加到请求上下文ctx中
  app.use(nunjucksMiddleware({
    // 指定模板文件夹
    path: path.resolve(__dirname, '../views')
  })
  /* 路由中调用 以routers/home.js 为例 修改代码如下*/
  const homeRouter = require('koa-router')()
  homeRouter.get(['/', '/index.html', '/index', '/home.html', '/home'], (ctx, next) => {
    // 渲染页面的数据
    ctx.state.todoList = [
      {name: '吃饭', time: '2019.1.4 12:00'},
      {name: '下午茶', time: '2019.1.4 15:10'},
      {name: '下班', time: '2019.1.4 18:30'}
    ]
    // 这里的ctx.render方法就是我们通过nunjucksMiddleware中间件添加的
    ctx.render('home/home', {
      title: '首页'
    })
  })
  module.exports = homeRouter

第四步 抽取公共模板

目标: 抽取页面的公用部分 如导航/底部/html模板等
  /**views目录下增加两个文件夹_layout(公用模板) _component(公共组件) 目录结构如下
    │─.gitignore
    │─package.json
    │─README.md
    ├─bin
    │   │─www.js  /koa服务
    │─middlewares  //中间件目录  
    │   ├─nunjucksMiddleware.js  //nunjucks模板中间件
    ├─node_modules
    │─routers  //服务路由目录
    │   │─home.js
    │   │─index.js
    │   │─user.js 
    │─views  //页面视图层
        │─_component
        │   │─nav.html (公用导航)
        │─_layout
        │   │─layout.html  (公用html框架)
        ├─home
        │   ├─home.html 
        ├─user
            ├─user.html
  */
  <!-- layout.html 文件代码 -->
  <!DOCTYPE html>
  <html>
  <head>
    <meta charset="UTF-8">
    <title>{{ title }}</title>
  </head>
  <body>
    <!-- 占位 名称为content的block将放在此处 -->
    {% block content %}
    {% endblock %}
  </body>
  </html>


  <!-- nav.html  公用导航  -->
  <ul>
    <li><a href="/">首页</a></li>
    <li><a href="/user">用户页</a></li>
  </ul>
  <!-- home.html 改写 -->
  <!-- njk继承模板 -->
  {% extends "../_layout/layout.html" %}
  {% block content %}
    <!-- njk引入公共模块 -->
    {% include "../_component/nav.html" %}
    <h1>待办事项</h1>
    <ul>
      <!-- 过滤器的调用 timeFormate即我们在中间件中给njk加的过滤器 -->
      {% for item in todoList %}
        <li>{{item.name}} ---> {{item.time | timeFormate}}</li>
      {% endfor %}
    </ul>
  {% endblock %}


  <!-- user.html -->
  {% extends "../_layout/layout.html" %}
  {% block content %}
    {% include "../_component/nav.html" %}
    用户中心
  {% endblock %}

第五步 静态资源处理

目标: 处理页面jscssimg等资源引入
  • 依赖

    1. 用webpack打包静态资源 npm i webpack webpack-cli -D
    2. 处理js npm i @babel/core @babel/preset-env babel-loader -D
    3. 处理less npm i css-loader less-loader less mini-css-extract-plugin -D
    4. 处理文件 npm i file-loader copy-webpack-plugin -D
    5. 处理html npm i html-webpack-plugin -D
    6. 清理打包文件 npm i clean-webpack-plugin -D
> *相关插件使用 查看npm相关文档*
  /* 项目目录 变更 
  │  .gitignore
  │  package.json
  │  README.md
  ├─bin
  │  www.js
  ├─config  //增加webpack配置目录
  │  webpack.config.js
  ├─middlewares
  │  nunjucksMiddleware.js
  ├─routers
  │  home.js
  │  index.js
  │  user.js
  ├─src
  │  │─template.html  // + html模板 以此模板为每个入口生成 引入对应js的模板
  │  ├─images // +图资源目录
  │  │  ww.jpg
  │  ├─js // + js目录 
  │  │  ├─home
  │  │  │   home.js
  │  │  └─user
  │  │      user.js
  │  └─less // + css目录
  │      ├─common
  │      │   common.less
  │      │   nav.less
  │      ├─home
  │      │   home.less
  │      └─user
  │          user.less
  └─views
      ├─home
      │  home.html
      ├─user
      │  user.html
      ├─_component
      │      nav.html
      └─_layout  // webpac打包后的html模板
          ├─home
          │   home.html
          └─user
              user.html
  */
  <!--  template.html 内容-->
  <!DOCTYPE html>
  <html lang="en">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>{{title}}</title>
  </head>
  <body>
    <!-- njk模板继承后填充 -->
    {% block content %}
    {% endblock %}
  </body>
  </html>
  /* src/js/home/home.js 一个入口文件*/
  
  import '../../less/home/home.less' //引入css
  import img from '../../images/ww.jpg' //引入图片
  console.log(111);
  let add = (a, b) => a + b; //箭头函数
  let a = 3, b = 4;
  let c = add(a, b);
  console.log(c);
  // 这里只做打包演示代码 不具任何意义
  <!-- less/home/home.less 内容 -->
  // 引入公共样式
  @import '../common/common.less';
  @import '../common/nav.less';

  .list {
    li {
      color: rebeccapurple;
    }
  }
  .bg-img {
    width: 200px;
    height: 200px;
    background: url(../../images/ww.jpg); // 背景图片
    margin: 10px 0;
  }
  /* webpack配置  webpack.config.js */
  const path = require('path');
  const CleanWebpackPlugin = require('clean-webpack-plugin');
  const HtmlWebpackPlugin = require('html-webpack-plugin');
  const MiniCssExtractPlugin = require("mini-css-extract-plugin");
  const CopyWebpackPlugin = require('copy-webpack-plugin');

  // 多入口
  let entry = {
    home: 'src/js/home/home.js',
    user: 'src/js/user/user.js'
  }

  module.exports = evn => ({
    mode: evn.production ? 'production' : 'development',
    // 给每个入口 path.reslove 
    entry: Object.keys(entry).reduce((obj, item) => (obj[item] = path.resolve(entry[item])) && obj, {}),
    output: {
      publicPath: '/',
      filename: 'js/[name].js',
      path: path.resolve('dist')
    },
    module: {
      rules: [
        { // bable 根据需要转换到对应版本 
          test: /\.js$/,
          exclude: /node_modules/,
          use: {
            loader: 'babel-loader',
            options: {
              presets: ['@babel/preset-env']
            }
          }
        },
        { // 转换less 并交给MiniCssExtractPlug插件提取到单独文件
          test: /\.less$/,
          loader: [MiniCssExtractPlugin.loader,  'css-loader', 'less-loader'],
          exclude: /node_modules/
        },
        { //将css、js引入的图片目录指到dist目录下的images 保持与页面引入的一致
          test: /\.(png|svg|jpg|gif)$/,
          use: [{
            loader: 'file-loader',
            options: {
              name: '[name].[ext]',
              outputPath: './images',
          }
          }]
        },
        {
          test: /\.(woff|woff2|eot|ttf|otf)$/,
          use: [{
            loader: 'file-loader',
            options: {
              name: '[name].[ext]',
              outputPath: './font',
          }
          }]
        }
      ]
    },
    plugins: [
      // 删除上一次打包目录(一般来说删除自己输出过的目录 )
      new CleanWebpackPlugin(['dist', 'views/_layout'], {
        // 当配置文件与package.json不再同一目录时候需要指定根目录
        root: path.resolve() 
      }),
      new MiniCssExtractPlugin({
        filename: "css/[name].css",
        chunkFilename: "[id].css"
      }),
      // 将src下的图片资源平移到dist目录
      new CopyWebpackPlugin(
        [{
          from: path.resolve('src/images'),
          to: path.resolve('dist/images')
        }
      ]),
      // HtmlWebpackPlugin 每个入口生成一个html 并引入对应打包生产好的js
      ...Object.keys(entry).map(item => new HtmlWebpackPlugin({
        // 模块名对应入口名称
        chunks: [item], 
        // 输入目录 (可自行定义 这边输入到views下面的_layout)
        filename: path.resolve('views/_layout/' + entry[item].split('/').slice(-2).join('/').replace('js', 'html')),
        // 基准模板
        template: path.resolve('src/template.html')
      }))
    ]
  });

  <!-- package.json中添加 -->
  "scripts": {
    "start": "node bin/www.js",
    "build": "webpack --env.production --config config/webpack.config.js"
  }

  运行 npm run build 后生成 dist views/_layout 两个目录
  <!-- 查看打包后生成的模板 views/_layout/home/home.html-->
  <!DOCTYPE html>
  <html lang="en">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>{{title}}</title>
    <!-- 引入了css文件 -->
  <link href="/css/home.css" rel="stylesheet"></head>
  <body>
    {% block content %}
    {% endblock %}
    <!-- 引入了js文件 此时打包后的js/css在dist目录下面 -->
  <script type="text/javascript" data-original="/js/home.js"></script></body>
  </html>
  <!-- view/home/home.html 页面改写 -->
  <!-- njk继承模板 继承的目标来自webpack打包生成 -->
  {% extends "../_layout/home/home.html" %}
  {% block content %}
    <!-- njk引入公共模块 -->
    {% include "../_component/nav.html" %}
    <h1>待办事项</h1>
    <ul class="list">
      <!-- 过滤器的调用 timeFormate即我们在中间件中给njk加的过滤器 -->
      {% for item in todoList %}
        <li>{{item.name}} ---> {{item.time | timeFormate}}</li>
      {% endfor %}
    </ul>
    <div class="bg-img"> 背景图</div>
    <!-- 页面图片引入方式 -->
    <img data-original="/images/ww.jpg"/>
  {% endblock %}
  /**koa处理静态资源 
   * 依赖 npm i 'koa-static
  */

  // www.js 增加 将静态资源目录指向 打包后的dist目录
  app.use(require('koa-static')(path.resolve('dist')))
运行
npm run build
npm start
浏览器访问127.0.0.1:3000 查看页面 js css img 效果

第六步 监听编译

目标: 文件发生改实时编译打包
  • 依赖 npm i pm2 concurrently

      /**项目中文件发生变动 需要重启服务才能看到效果是一件蛋疼的事,故需要实时监听变动 */
      <!-- 我们要监听的有两点 一是node服务 而是webpack打包 package.json变动如下 -->
        "scripts": {
          // concurrently 监听同时监听两条命令
          "start": "concurrently \"npm run build:dev\" \"npm run server:dev\"",
          "dev": "npm start",
          // 生产环境 执行两条命令即可 无监听
          "product": "npm run build:pro && npm run server:pro",
          // pm2 --watch参数监听服务的代码变更
          "server:dev": "pm2 start bin/www.js --watch",
          // 生产不需要用监听
          "server:pro": "pm2 start bin/www.js",
          // webpack --watch 对打包文件监听
          "build:dev": "webpack --watch --env.production --config config/webpack.config.js",
          "build:pro": "webpack --env.production --config config/webpack.config.js"
        }

第七步 数据请求

目标: node请求接口数据 填充模板
  • 依赖 npm i node-fetch

      /*上面的代码中routers/home.js首页路由中我们向页面渲染了下面的一组数据 */
      ctx.state.todoList = [
        {name: '吃饭', time: '2019.1.4 12:00'},
        {name: '下午茶', time: '2019.1.4 15:10'},
        {name: '下班1', time: '2019.1.4 18:30'}
      ]
      /*但 数据是同步的 项目中我们必然会向java获取其他后台拿到渲染数据再填充页面 我们来看看怎么做*/
        /*我们在根目录下创建一个util的目录作为工具库 并简单封装fetch.js请求数据*/
      const nodeFetch = require('node-fetch')
      module.exports = ({url, method, data = {}}) => {
        // get请求 将参数拼到url
        url = method === 'get' || !method ? "?" + Object.keys(data).map(item => `${item}=${data[item]}`).join('&') : url;
        return nodeFetch(url, {
              method: method || 'get',
              body:  JSON.stringify(data),
              headers: { 'Content-Type': 'application/json' },
          }).then(res => res.json())
      }
      /*在根目录下创建一个service的目录作为数据层 并创建一个exampleService.js 作为示例*/
      //引入封装的 请求工具
      const fetch = require('../util/fetch.js')
      module.exports = {
        getTodoList (params = {}) {
          return fetch({
            url: 'https://www.easy-mock.com/mock/5c35a2a2ce7b4303bd93fbda/example/todolist',
            method: 'post',
            data: params
          })
        },
        //...
      }
      /* 将请求加入到路由中 routers/home.js 改写 */
      const homeRouter = require('koa-router')()
      let exampleService = require('../service/exampleService.js') // 引入service api
      //将路由匹配回调 改成async函数 并在请时候 await数据回来 再调用render
      homeRouter.get(['/', '/index.html', '/index', '/home.html', '/home'], async (ctx, next) => {
        // 请求数据
        let todoList = await exampleService.getTodoList({name: 'ott'})
        // 替换原来的静态数据
        ctx.state.todoList = todoList.data
        ctx.render('home/home', {
          title: '首页'
        })
      })
      // 导出路由备用
      module.exports = homeRouter

第八步 日志打印

目标: 使程序运行可视
  • 依赖 npm i log4js

      /* 在util目录下创建 logger.js 代码如下 作简单的logger封装 */
      const log4js = require('log4js');
      const path = require('path')
      // 定义log config
      log4js.configure({
        appenders: { 
          // 定义两个输出源
          info: { type: 'file', filename: path.resolve('log/info.log') },
          error: { type: 'file', filename: path.resolve('log/error.log') }
        },
        categories: { 
          // 为info/warn/debug 类型log调用info输出源   error/fatal 调用error输出源
          default: { appenders: ['info'], level: 'info' },
          info: { appenders: ['info'], level: 'info' },
          warn: { appenders: ['info'], level: 'warn' },
          debug: { appenders: ['info'], level: 'debug' },
          error: { appenders: ['error'], level: 'error' },
          fatal: { appenders: ['error'], level: 'fatal' },
        }
      });
      // 导出5种类型的 logger
      module.exports = {
        debug: (...params) => log4js.getLogger('debug').debug(...params),
        info: (...params) => log4js.getLogger('info').info(...params),
        warn: (...params) => log4js.getLogger('warn').warn(...params),
        error: (...params) => log4js.getLogger('error').error(...params),
        fatal: (...params) => log4js.getLogger('fatal').fatal(...params),
      }
      /* 在fetch.js中是哟logger */
      const nodeFetch = require('node-fetch')
      const logger = require('./logger.js')
    
      module.exports = ({url, method, data = {}}) => {
        // 加入请求日志
        logger.info('请求url:', url , method||'get', JSON.stringify(data))
    
        // get请求 将参数拼到url
        url = method === 'get' || !method ? "?" + Object.keys(data).map(item => `${item}=${data[item]}`).join('&') : url;
    
        return nodeFetch(url, {
          method: method || 'get',
            body:  JSON.stringify(data),
            headers: { 'Content-Type': 'application/json' },
        }).then(res => res.json())
      }
    
      <!-- 日志打印 -->
      [2019-01-09T17:34:11.404] [INFO] info - 请求url: https://www.easy-mock.com/mock/5c35a2a2ce7b4303bd93fbda/example/todolist post {"name":"ott"}
    
注: 仅共学习参考,生产配置自行斟酌!转载请备注来源!
查看原文

款冬 关注了用户 · 2018-12-31

边城 @jamesfancy

从事软件开发 20 年,在软件分析、设计、架构、开发及软件开发技术研究和培训等方面有着非常丰富的经验,近年主要在研究 Web 前端技术、基于 .NET 的后端开发技术和相关软件架构。

关注 11113

款冬 收藏了文章 · 2018-12-02

理解 JavaScript 的 async/await

2020-06-04 更新

JavaScript 中的 async/await 是 AsyncFunction 特性 中的关键字。目前为止,除了 IE 之外,常用浏览器和 Node (v7.6+) 都已经支持该特性。具体支持情况可以在 这里 查看。


我第一次看到 async/await 这组关键字并不是在 JavaScript 语言里,而是在 C# 5.0 的语法中。C# 的 async/await 需要在 .NET Framework 4.5 以上的版本中使用,因此我还很悲伤了一阵——为了要兼容 XP 系统,我们开发的软件不能使用高于 4.0 版本的 .NET Framework。

我之前在《闲谈异步调用“扁平”化》 中就谈到了这个问题。无论是在 C# 还是 JavaScript 中,async/await 都是非常棒的特性,它们也都是非常甜的语法糖。C# 的 async/await 实现离不开 Task 或 Task\<Result\> 类,而 JavaScript 的 async/await 实现,也离不开 Promise

现在抛开 C# 和 .NET Framework,专心研究下 JavaScript 的 async/await。

1. async 和 await 在干什么

任意一个名称都是有意义的,先从字面意思来理解。async 是“异步”的简写,而 await 可以认为是 async wait 的简写。所以应该很好理解 async 用于申明一个 function 是异步的,而 await 用于等待一个异步方法执行完成。

另外还有一个很有意思的语法规定,await 只能出现在 async 函数中。然后细心的朋友会产生一个疑问,如果 await 只能出现在 async 函数中,那这个 async 函数应该怎么调用?

如果需要通过 await 来调用一个 async 函数,那这个调用的外面必须得再包一个 async 函数,然后……进入死循环,永无出头之日……

如果 async 函数不需要 await 来调用,那 async 到底起个啥作用?

1.1. async 起什么作用

这个问题的关键在于,async 函数是怎么处理它的返回值的!

我们当然希望它能直接通过 return 语句返回我们想要的值,但是如果真是这样,似乎就没 await 什么事了。所以,写段代码来试试,看它到底会返回什么:

async function testAsync() {
    return "hello async";
}

const result = testAsync();
console.log(result);

看到输出就恍然大悟了——输出的是一个 Promise 对象。

c:\var\test> node --harmony_async_await .
Promise { 'hello async' }

所以,async 函数返回的是一个 Promise 对象。从文档中也可以得到这个信息。async 函数(包含函数语句、函数表达式、Lambda表达式)会返回一个 Promise 对象,如果在函数中 return 一个直接量,async 会把这个直接量通过 Promise.resolve() 封装成 Promise 对象。

补充知识点 [2020-06-04]

Promise.resolve(x) 可以看作是 new Promise(resolve => resolve(x)) 的简写,可以用于快速封装字面量对象或其他对象,将其封装成 Promise 实例。

async 函数返回的是一个 Promise 对象,所以在最外层不能用 await 获取其返回值的情况下,我们当然应该用原来的方式:then() 链来处理这个 Promise 对象,就像这样

testAsync().then(v => {
    console.log(v);    // 输出 hello async
});

现在回过头来想下,如果 async 函数没有返回值,又该如何?很容易想到,它会返回 Promise.resolve(undefined)

联想一下 Promise 的特点——无等待,所以在没有 await 的情况下执行 async 函数,它会立即执行,返回一个 Promise 对象,并且,绝不会阻塞后面的语句。这和普通返回 Promise 对象的函数并无二致。

那么下一个关键点就在于 await 关键字了。

1.2. await 到底在等啥

一般来说,都认为 await 是在等待一个 async 函数完成。不过按语法说明,await 等待的是一个表达式,这个表达式的计算结果是 Promise 对象或者其它值(换句话说,就是没有特殊限定)。

因为 async 函数返回一个 Promise 对象,所以 await 可以用于等待一个 async 函数的返回值——这也可以说是 await 在等 async 函数,但要清楚,它等的实际是一个返回值。注意到 await 不仅仅用于等 Promise 对象,它可以等任意表达式的结果,所以,await 后面实际是可以接普通函数调用或者直接量的。所以下面这个示例完全可以正确运行

function getSomething() {
    return "something";
}

async function testAsync() {
    return Promise.resolve("hello async");
}

async function test() {
    const v1 = await getSomething();
    const v2 = await testAsync();
    console.log(v1, v2);
}

test();

1.3. await 等到了要等的,然后呢

await 等到了它要等的东西,一个 Promise 对象,或者其它值,然后呢?我不得不先说,await 是个运算符,用于组成表达式,await 表达式的运算结果取决于它等的东西。

如果它等到的不是一个 Promise 对象,那 await 表达式的运算结果就是它等到的东西。

如果它等到的是一个 Promise 对象,await 就忙起来了,它会阻塞后面的代码,等着 Promise 对象 resolve,然后得到 resolve 的值,作为 await 表达式的运算结果。

看到上面的阻塞一词,心慌了吧……放心,这就是 await 必须用在 async 函数中的原因。async 函数调用不会造成阻塞,它内部所有的阻塞都被封装在一个 Promise 对象中异步执行。

2. async/await 帮我们干了啥

2.1. 作个简单的比较

上面已经说明了 async 会将其后的函数(函数表达式或 Lambda)的返回值封装成一个 Promise 对象,而 await 会等待这个 Promise 完成,并将其 resolve 的结果返回出来。

现在举例,用 setTimeout 模拟耗时的异步操作,先来看看不用 async/await 会怎么写

function takeLongTime() {
    return new Promise(resolve => {
        setTimeout(() => resolve("long_time_value"), 1000);
    });
}

takeLongTime().then(v => {
    console.log("got", v);
});

如果改用 async/await 呢,会是这样

function takeLongTime() {
    return new Promise(resolve => {
        setTimeout(() => resolve("long_time_value"), 1000);
    });
}

async function test() {
    const v = await takeLongTime();
    console.log(v);
}

test();

眼尖的同学已经发现 takeLongTime() 没有申明为 async。实际上,takeLongTime() 本身就是返回的 Promise 对象,加不加 async 结果都一样,如果没明白,请回过头再去看看上面的“async 起什么作用”。

又一个疑问产生了,这两段代码,两种方式对异步调用的处理(实际就是对 Promise 对象的处理)差别并不明显,甚至使用 async/await 还需要多写一些代码,那它的优势到底在哪?

2.2. async/await 的优势在于处理 then 链

单一的 Promise 链并不能发现 async/await 的优势,但是,如果需要处理由多个 Promise 组成的 then 链的时候,优势就能体现出来了(很有意思,Promise 通过 then 链来解决多层回调的问题,现在又用 async/await 来进一步优化它)。

假设一个业务,分多个步骤完成,每个步骤都是异步的,而且依赖于上一个步骤的结果。我们仍然用 setTimeout 来模拟异步操作:

/**
 * 传入参数 n,表示这个函数执行的时间(毫秒)
 * 执行的结果是 n + 200,这个值将用于下一步骤
 */
function takeLongTime(n) {
    return new Promise(resolve => {
        setTimeout(() => resolve(n + 200), n);
    });
}

function step1(n) {
    console.log(`step1 with ${n}`);
    return takeLongTime(n);
}

function step2(n) {
    console.log(`step2 with ${n}`);
    return takeLongTime(n);
}

function step3(n) {
    console.log(`step3 with ${n}`);
    return takeLongTime(n);
}

现在用 Promise 方式来实现这三个步骤的处理

function doIt() {
    console.time("doIt");
    const time1 = 300;
    step1(time1)
        .then(time2 => step2(time2))
        .then(time3 => step3(time3))
        .then(result => {
            console.log(`result is ${result}`);
            console.timeEnd("doIt");
        });
}

doIt();

// c:\var\test>node --harmony_async_await .
// step1 with 300
// step2 with 500
// step3 with 700
// result is 900
// doIt: 1507.251ms

输出结果 resultstep3() 的参数 700 + 200 = 900doIt() 顺序执行了三个步骤,一共用了 300 + 500 + 700 = 1500 毫秒,和 console.time()/console.timeEnd() 计算的结果一致。

如果用 async/await 来实现呢,会是这样

async function doIt() {
    console.time("doIt");
    const time1 = 300;
    const time2 = await step1(time1);
    const time3 = await step2(time2);
    const result = await step3(time3);
    console.log(`result is ${result}`);
    console.timeEnd("doIt");
}

doIt();

结果和之前的 Promise 实现是一样的,但是这个代码看起来是不是清晰得多,几乎跟同步代码一样

2.3. 还有更酷的

现在把业务要求改一下,仍然是三个步骤,但每一个步骤都需要之前每个步骤的结果。

function step1(n) {
    console.log(`step1 with ${n}`);
    return takeLongTime(n);
}

function step2(m, n) {
    console.log(`step2 with ${m} and ${n}`);
    return takeLongTime(m + n);
}

function step3(k, m, n) {
    console.log(`step3 with ${k}, ${m} and ${n}`);
    return takeLongTime(k + m + n);
}

这回先用 async/await 来写:

async function doIt() {
    console.time("doIt");
    const time1 = 300;
    const time2 = await step1(time1);
    const time3 = await step2(time1, time2);
    const result = await step3(time1, time2, time3);
    console.log(`result is ${result}`);
    console.timeEnd("doIt");
}

doIt();

// c:\var\test>node --harmony_async_await .
// step1 with 300
// step2 with 800 = 300 + 500
// step3 with 1800 = 300 + 500 + 1000
// result is 2000
// doIt: 2907.387ms

除了觉得执行时间变长了之外,似乎和之前的示例没啥区别啊!别急,认真想想如果把它写成 Promise 方式实现会是什么样子?

function doIt() {
    console.time("doIt");
    const time1 = 300;
    step1(time1)
        .then(time2 => {
            return step2(time1, time2)
                .then(time3 => [time1, time2, time3]);
        })
        .then(times => {
            const [time1, time2, time3] = times;
            return step3(time1, time2, time3);
        })
        .then(result => {
            console.log(`result is ${result}`);
            console.timeEnd("doIt");
        });
}

doIt();

有没有感觉有点复杂的样子?那一堆参数处理,就是 Promise 方案的死穴—— 参数传递太麻烦了,看着就晕!

3. 洗洗睡吧

就目前来说,已经理解 async/await 了吧?但其实还有一些事情没提及——Promise 有可能 reject 啊,怎么处理呢?如果需要并行处理3个步骤,再等待所有结果,又该怎么处理呢?

阮一峰老师已经说过了,我就懒得说了。

4. 推荐相关文章

5. 来跟边城(作者)学 更新@2020-11-14

TypeScript从入门到实践 【2020 版】

TypeScript从入门到实践 【2020 版】

6. 关于转载 补充@2020-03-05

常有读者问是否可以转载。

笔者表示欢迎各位转载,但转载时一定注明作者和出处,谢谢!


公众号-边城客栈
请关注公众号 边城客栈

看完了先别走,点个赞啊 ⇓,赞赏 ⇘ 也行!

查看原文

款冬 收藏了文章 · 2018-11-04

exports、module.exports和export、export default到底是咋回事

前言

难得有空,今天开始重新规范的学习一下node编程。
但是引入模块我看到用 require的方式,再联想到咱们的ES6各种exportexport default

阿西吧,头都大了....

头大完了,那我们坐下先理理他们的使用范围。

require: node 和 es6 都支持的引入
export / import : 只有es6 支持的导出引入
module.exports / exports: 只有 node 支持的导出

这一刻起,我觉得是时候要把它们之间的关系都给捋清楚了,不然我得混乱死。话不多少,咱们开干!!

node模块

Node里面的模块系统遵循的是CommonJS规范。
那问题又来了,什么是CommonJS规范呢?
由于js以前比较混乱,各写各的代码,没有一个模块的概念,而这个规范出来其实就是对模块的一个定义。

CommonJS定义的模块分为: 模块标识(module)、模块定义(exports) 、模块引用(require)

先解释 exportsmodule.exports
在一个node执行一个文件时,会给这个文件内生成一个 exportsmodule对象,
module又有一个exports属性。他们之间的关系如下图,都指向一块{}内存区域。

exports = module.exports = {};

clipboard.png

那下面我们来看看代码的吧。

//utils.js
let a = 100;

console.log(module.exports); //能打印出结果为:{}
console.log(exports); //能打印出结果为:{}

exports.a = 200; //这里辛苦劳作帮 module.exports 的内容给改成 {a : 200}

exports = '指向其他内存区'; //这里把exports的指向指走

//test.js

var a = require('/utils');
console.log(a) // 打印为 {a : 200} 

从上面可以看出,其实require导出的内容是module.exports的指向的内存块内容,并不是exports的。
简而言之,区分他们之间的区别就是 exports 只是 module.exports的引用,辅助后者添加内容用的。

用白话讲就是,exports只辅助module.exports操作内存中的数据,辛辛苦苦各种操作数据完,累得要死,结果到最后真正被require出去的内容还是module.exports的,真是好苦逼啊。

其实大家用内存块的概念去理解,就会很清楚了。

然后呢,为了避免糊涂,尽量都用 module.exports 导出,然后用require导入。

ES中的模块导出导入

说实话,在es中的模块,就非常清晰了。不过也有一些细节的东西需要搞清楚。
比如 exportexport default,还有 导入的时候,import a from ..,import {a} from ..,总之也有点乱,那么下面我们就开始把它们捋清楚吧。

export 和 export default

首先我们讲这两个导出,下面我们讲讲它们的区别

  1. export与export default均可用于导出常量、函数、文件、模块等
  2. 在一个文件或模块中,export、import可以有多个,export default仅有一个
  3. 通过export方式导出,在导入时要加{ },export default则不需要
  4. export能直接导出变量表达式,export default不行。

下面咱们看看代码去验证一下

testEs6Export.js

'use strict'
//导出变量
export const a = '100';  

 //导出方法
export const dogSay = function(){ 
    console.log('wang wang');
}

 //导出方法第二种
function catSay(){
   console.log('miao miao'); 
}
export { catSay };

//export default导出
const m = 100;
export default m; 
//export defult const m = 100;// 这里不能写这种格式。

index.js

//index.js
'use strict'
var express = require('express');
var router = express.Router();

import { dogSay, catSay } from './testEs6Export'; //导出了 export 方法 
import m from './testEs6Export';  //导出了 export default 

import * as testModule from './testEs6Export'; //as 集合成对象导出



/* GET home page. */
router.get('/', function(req, res, next) {
  dogSay();
  catSay();
  console.log(m);
  testModule.dogSay();
  console.log(testModule.m); // undefined , 因为  as 导出是 把 零散的 export 聚集在一起作为一个对象,而export default 是导出为 default属性。
  console.log(testModule.default); // 100
  res.send('恭喜你,成功验证');
});

module.exports = router;

从上面可以看出,确实感觉 ES6的模块系统非常灵活的。

代码地址

GitHub: https://github.com/XuXiaoGH/e...

参考文献

1.老树新芽,在ES6下使用Express
2.exports 和 module.exports 的区别
3.module.exports与exports,export与export default之间的关系

感谢这三位前辈的分享。

写在最后

如果文章对你有所帮助,不妨点个赞或者收藏一下,这将是支持我继续写下去的动力。

谢谢亲们。

查看原文

款冬 赞了文章 · 2018-11-04

exports、module.exports和export、export default到底是咋回事

前言

难得有空,今天开始重新规范的学习一下node编程。
但是引入模块我看到用 require的方式,再联想到咱们的ES6各种exportexport default

阿西吧,头都大了....

头大完了,那我们坐下先理理他们的使用范围。

require: node 和 es6 都支持的引入
export / import : 只有es6 支持的导出引入
module.exports / exports: 只有 node 支持的导出

这一刻起,我觉得是时候要把它们之间的关系都给捋清楚了,不然我得混乱死。话不多少,咱们开干!!

node模块

Node里面的模块系统遵循的是CommonJS规范。
那问题又来了,什么是CommonJS规范呢?
由于js以前比较混乱,各写各的代码,没有一个模块的概念,而这个规范出来其实就是对模块的一个定义。

CommonJS定义的模块分为: 模块标识(module)、模块定义(exports) 、模块引用(require)

先解释 exportsmodule.exports
在一个node执行一个文件时,会给这个文件内生成一个 exportsmodule对象,
module又有一个exports属性。他们之间的关系如下图,都指向一块{}内存区域。

exports = module.exports = {};

clipboard.png

那下面我们来看看代码的吧。

//utils.js
let a = 100;

console.log(module.exports); //能打印出结果为:{}
console.log(exports); //能打印出结果为:{}

exports.a = 200; //这里辛苦劳作帮 module.exports 的内容给改成 {a : 200}

exports = '指向其他内存区'; //这里把exports的指向指走

//test.js

var a = require('/utils');
console.log(a) // 打印为 {a : 200} 

从上面可以看出,其实require导出的内容是module.exports的指向的内存块内容,并不是exports的。
简而言之,区分他们之间的区别就是 exports 只是 module.exports的引用,辅助后者添加内容用的。

用白话讲就是,exports只辅助module.exports操作内存中的数据,辛辛苦苦各种操作数据完,累得要死,结果到最后真正被require出去的内容还是module.exports的,真是好苦逼啊。

其实大家用内存块的概念去理解,就会很清楚了。

然后呢,为了避免糊涂,尽量都用 module.exports 导出,然后用require导入。

ES中的模块导出导入

说实话,在es中的模块,就非常清晰了。不过也有一些细节的东西需要搞清楚。
比如 exportexport default,还有 导入的时候,import a from ..,import {a} from ..,总之也有点乱,那么下面我们就开始把它们捋清楚吧。

export 和 export default

首先我们讲这两个导出,下面我们讲讲它们的区别

  1. export与export default均可用于导出常量、函数、文件、模块等
  2. 在一个文件或模块中,export、import可以有多个,export default仅有一个
  3. 通过export方式导出,在导入时要加{ },export default则不需要
  4. export能直接导出变量表达式,export default不行。

下面咱们看看代码去验证一下

testEs6Export.js

'use strict'
//导出变量
export const a = '100';  

 //导出方法
export const dogSay = function(){ 
    console.log('wang wang');
}

 //导出方法第二种
function catSay(){
   console.log('miao miao'); 
}
export { catSay };

//export default导出
const m = 100;
export default m; 
//export defult const m = 100;// 这里不能写这种格式。

index.js

//index.js
'use strict'
var express = require('express');
var router = express.Router();

import { dogSay, catSay } from './testEs6Export'; //导出了 export 方法 
import m from './testEs6Export';  //导出了 export default 

import * as testModule from './testEs6Export'; //as 集合成对象导出



/* GET home page. */
router.get('/', function(req, res, next) {
  dogSay();
  catSay();
  console.log(m);
  testModule.dogSay();
  console.log(testModule.m); // undefined , 因为  as 导出是 把 零散的 export 聚集在一起作为一个对象,而export default 是导出为 default属性。
  console.log(testModule.default); // 100
  res.send('恭喜你,成功验证');
});

module.exports = router;

从上面可以看出,确实感觉 ES6的模块系统非常灵活的。

代码地址

GitHub: https://github.com/XuXiaoGH/e...

参考文献

1.老树新芽,在ES6下使用Express
2.exports 和 module.exports 的区别
3.module.exports与exports,export与export default之间的关系

感谢这三位前辈的分享。

写在最后

如果文章对你有所帮助,不妨点个赞或者收藏一下,这将是支持我继续写下去的动力。

谢谢亲们。

查看原文

赞 198 收藏 244 评论 17