前端荣耀

前端荣耀 查看完整档案

北京编辑中国海洋大学  |  计算机科学与技术 编辑58  |  前端工程师 编辑 github.com/lengxing/MyBlog 编辑
编辑

一个在程序猿修炼生涯中的修行者

个人动态

前端荣耀 收藏了文章 · 4月13日

Vue3+TypeScript封装axios并进行请求调用

 不是吧,不是吧,原来真的有人都2021年了,连TypeScript都没听说过吧?在项目中使用TypeScript虽然短期内会增加一些开发成本,但是对于其需要长期维护的项目,TypeScript能够减少其维护成本,使用TypeScript增加了代码的可读性和可维护性,且拥有较为活跃的社区,当居为大前端的趋势所在,那就开始淦起来吧~

使用TypeScript封装基础axios库

代码如下:

// http.ts
import axios, { AxiosRequestConfig, AxiosResponse } from 'axios'
import { ElMessage } from "element-plus"

const showStatus = (status: number) => {
  let message = ''
  switch (status) {
    case 400:
      message = '请求错误(400)'
      break
    case 401:
      message = '未授权,请重新登录(401)'
      break
    case 403:
      message = '拒绝访问(403)'
      break
    case 404:
      message = '请求出错(404)'
      break
    case 408:
      message = '请求超时(408)'
      break
    case 500:
      message = '服务器错误(500)'
      break
    case 501:
      message = '服务未实现(501)'
      break
    case 502:
      message = '网络错误(502)'
      break
    case 503:
      message = '服务不可用(503)'
      break
    case 504:
      message = '网络超时(504)'
      break
    case 505:
      message = 'HTTP版本不受支持(505)'
      break
    default:
      message = `连接出错(${status})!`
  }
  return `${message},请检查网络或联系管理员!`
}

const service = axios.create({
  // 联调
  // baseURL: process.env.NODE_ENV === 'production' ? `/` : '/api',
  baseURL: "/api",
  headers: {
    get: {
      'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8'
    },
    post: {
      'Content-Type': 'application/json;charset=utf-8'
    }
  },
  // 是否跨站点访问控制请求
  withCredentials: true,
  timeout: 30000,
  transformRequest: [(data) => {
    data = JSON.stringify(data)
    return data
  }],
  validateStatus() {
    // 使用async-await,处理reject情况较为繁琐,所以全部返回resolve,在业务代码中处理异常
    return true
  },
  transformResponse: [(data) => {
    if (typeof data === 'string' && data.startsWith('{')) {
      data = JSON.parse(data)
    }
    return data
  }]
  
})

// 请求拦截器
service.interceptors.request.use((config: AxiosRequestConfig) => {
  //获取token,并将其添加至请求头中
  let token = localStorage.getItem('token')
  if(token){
    config.headers.Authorization = `${token}`;
  }
  return config
}, (error) => {
  // 错误抛到业务代码
  error.data = {}
  error.data.msg = '服务器异常,请联系管理员!'
  return Promise.resolve(error)
})

// 响应拦截器
service.interceptors.response.use((response: AxiosResponse) => {
  const status = response.status
  let msg = ''
  if (status < 200 || status >= 300) {
    // 处理http错误,抛到业务代码
    msg = showStatus(status)
    if (typeof response.data === 'string') {
      response.data = { msg }
    } else {
      response.data.msg = msg
    }
  }
  return response
}, (error) => {
  if (axios.isCancel(error)) {
    console.log('repeated request: ' + error.message)
  } else {
    // handle error code
    // 错误抛到业务代码
    error.data = {}
    error.data.msg = '请求超时或服务器异常,请检查网络或联系管理员!'
    ElMessage.error(error.data.msg)
  }
  return Promise.reject(error)
})

export default service

取消多次重复的请求版本

 在上述代码加入如下代码:

// http.ts
import axios, { AxiosRequestConfig, AxiosResponse } from 'axios'
import qs from "qs"
import { ElMessage } from "element-plus"

// 声明一个 Map 用于存储每个请求的标识 和 取消函数
const pending = new Map()
/**
 * 添加请求
 * @param {Object} config 
 */
const addPending = (config: AxiosRequestConfig) => {
  const url = [
    config.method,
    config.url,
    qs.stringify(config.params),
    qs.stringify(config.data)
  ].join('&')
  config.cancelToken = config.cancelToken || new axios.CancelToken(cancel => {
    if (!pending.has(url)) { // 如果 pending 中不存在当前请求,则添加进去
      pending.set(url, cancel)
    }
  })
}
/**
 * 移除请求
 * @param {Object} config 
 */
const removePending = (config: AxiosRequestConfig) => {
  const url = [
    config.method,
    config.url,
    qs.stringify(config.params),
    qs.stringify(config.data)
  ].join('&')
  if (pending.has(url)) { // 如果在 pending 中存在当前请求标识,需要取消当前请求,并且移除
    const cancel = pending.get(url)
    cancel(url)
    pending.delete(url)
  }
}

/**
 * 清空 pending 中的请求(在路由跳转时调用)
 */
export const clearPending = () => {
  for (const [url, cancel] of pending) {
    cancel(url)
  }
  pending.clear()
}

// 请求拦截器
service.interceptors.request.use((config: AxiosRequestConfig) => {
  removePending(config) // 在请求开始前,对之前的请求做检查取消操作
  addPending(config) // 将当前请求添加到 pending 中
  let token = localStorage.getItem('token')
  if(token){
    config.headers.Authorization = `${token}`;
  }
  return config
}, (error) => {
  // 错误抛到业务代码
  error.data = {}
  error.data.msg = '服务器异常,请联系管理员!'
  return Promise.resolve(error)
})

// 响应拦截器
service.interceptors.response.use((response: AxiosResponse) => {

  removePending(response) // 在请求结束后,移除本次请求
  const status = response.status
  let msg = ''
  if (status < 200 || status >= 300) {
    // 处理http错误,抛到业务代码
    msg = showStatus(status)
    if (typeof response.data === 'string') {
      response.data = { msg }
    } else {
      response.data.msg = msg
    }
  }

  return response
}, (error) => {
  if (axios.isCancel(error)) {
    console.log('repeated request: ' + error.message)
  } else {
    // handle error code
    // 错误抛到业务代码
    error.data = {}
    error.data.msg = '请求超时或服务器异常,请检查网络或联系管理员!'
    ElMessage.error(error.data.msg)
  }
  return Promise.reject(error)
})

export default service

在路由跳转时撤销所有请求

 在路由文件index.ts中加入

import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'
import Login from '@/views/Login/Login.vue'
//引入在axios暴露出的clearPending函数
import { clearPending } from "@/api/axios"

....
....
....

const router = createRouter({
  history: createWebHistory(process.env.BASE_URL),
  routes
})

router.beforeEach((to, from, next) => {
  //在跳转路由之前,先清除所有的请求
  clearPending()
  // ...
  next()
})

export default router

使用封装的axios请求库

封装响应格式

// 接口响应通过格式
export interface HttpResponse {
  status: number
  statusText: string
  data: {
    code: number
    desc: string
    [key: string]: any
  }
}

封装接口方法

 举个栗子,进行封装User接口,代码如下~

import Axios from './axios'
import { HttpResponse } from '@/@types'
/**
 * @interface loginParams -登录参数
 * @property {string} username -用户名
 * @property {string} password -用户密码
 */
interface LoginParams {
  username: string
  password: string
}
//封装User类型的接口方法
export class UserService {
  /**
   * @description 查询User的信息
   * @param {number} teamId - 所要查询的团队ID
   * @return {HttpResponse} result
   */
  static async login(params: LoginParams): Promise<HttpResponse> {
    return Axios('/api/user', {
      method: 'get',
      responseType: 'json',
      params: {
        ...params
      },
    })
  }

  static async resgister(params: LoginParams): Promise<HttpResponse> {
    return Axios('/api/user/resgister', {
      method: 'get',
      responseType: 'json',
      params: {
        ...params
      },
    })
  }
}

项目中进行使用

 代码如下:

<template>
     <input type="text" v-model="Account" placeholder="请输入账号" name="username" >
     <input type="text" v-model="Password" placeholder="请输入密码" name="username" >
     <button @click.prevent="handleRegister()">登录</button>
</template>
<script lang="ts">
import { defineComponent, reactive, toRefs } from 'vue'
//引入接口
import { UserService } from '@/api/user'

export default defineComponent({
  setup() {
    const state = reactive({
      Account: 'admin', //账户
      Password: 'hhhh', //密码
    })

    const handleLogin = async () => {
      const loginParams = {
        username: state.Account,
        password: state.Password,
      }
      const res = await UserService.login(loginParams)
       console.log(res)
    }

    const handleRegister = async () => {
      const loginParams = {
        username: state.Account,
        password: state.Password,
      }
      const res = await UserService.resgister(loginParams)
      console.log(res)
    }
    return {
      ...toRefs(state),
      handleLogin,
      handleRegister 
    }
  },
})
</script>
查看原文

前端荣耀 收藏了文章 · 4月13日

中小型前端团队代码规范工程化最佳实践 - ESLint

前言

There are a thousand Hamlets in a thousand people's eyes.

一千个程序员,就有一千种代码风格。在前端开发中,有几个至今还在争论的代码风格差异:

  • 单引号还是双引号?
  • 代码行结束是否需要分号?
  • 两个空格还是四个空格?
  • ...

这几个代码风格差异在协同开发中经常会被互相吐槽,甚至不能忍受。

除此之外,由于 JavaScript 的灵活性,往往一段代码能有多种写法,这时候也会导致协同时差异。并且,有一些写法可能会导致不易发现的 bug,或者这些写法的性能不好,开发时也应该避免。

为了解决这类静态代码问题,每个团队都需要一个统一的 JavaScript 代码规范,团队成员都遵守这份代码规范来编写代码。当然,靠人来保障代码规范是不可靠的,需要有对应的工具来保障,ESLint 就是这个工具。

有的读者看到这里,可能会说:Prettier 也可以保证代码风格一致。是的,Prettier 确实可以按照设置的规则对代码进行统一格式化,后面的文章也会有对应的介绍。但是需要明确的一点是,Prettier 只会在格式上对代码进行格式化,一些隐藏的代码质量问题 Prettier 是无法发现的,而 ESLint 可以。

关于 ESLint

关于 ESLint,它的 Slogan 是 Find and fix problems in your JavaScript code。如上文所说,它可以发现并修复你 JavaScript 代码中的问题。来看一下官网上描述 ESLint 具备的三个特性:

  • Find Problems。ESLint 通过静态代码分析可以快速发现代码中的问题。ESLint 可以运行在大多数文本编辑器中,并且也可以在工作流中接入 ESLint
  • Fix Automatically。ESLint 发现的很多问题都可以自动修复
  • Customize。可以定制 ESLint 检查规则

基于以上描述,我们在前端工程化中可以这样使用 ESLint:

  1. 基于业界现有的 ESLint 规范和团队代码习惯定制一套统一的 ESLint 代码规则
  2. 将统一代码规则封装成 ESLint 规则包接入
  3. 将 ESLint 接入脚手架、编辑器以及研发工作流中

快速上手

先简单介绍一下如何使用 ESLint,如果已经有所了解的同学,可以直接跳过这一节。

新建一个包含 package.json 的目录(可以在空目录下执行 npm init -y),新建一个 index.js

// index.js
const name = 'axuebin'

安装 eslint :

npm install eslint --save-dev

然后执行 ./node_modules/.bin/eslint --init 或者 npx eslint --init 生成一个 ESLint 配置文件 .eslintc.js

module.exports = {
  env: {
    es2021: true,
  },
  extends: 'eslint:recommended',
  parserOptions: {
    ecmaVersion: 12,
  },
  rules: {},
};

生成好配置文件之后,就可以执行 ./node_modules/.bin/eslint index.js 或者 npx eslint index.js 命令对文件进行检查。结果如下:
image.png
index.js 中的代码命中了 no-unused-vars 这个规则,默认情况下,这个规则是会报 error 的,也就是 ESLint 不允许代码中出现未被使用的变量。这是一个好习惯,有利于代码的维护。

简单配置

我们来尝试配置 ESLint 的检查规则。以分号和引号举例,现在你作为团队代码规范的指定人,希望团队成员开发的代码,都是单引号带分号的。

打开 .eslintrc.js 配置文件,在 rules 中添加相关配置项:

module.exports = {
  env: {
    es2021: true,
  },
  extends: 'eslint:recommended',
  parserOptions: {
    ecmaVersion: 12,
  },
  rules: {
    semi: ['error', 'always'],
    quotes: ['error', 'single'],
  },
};

然后我们将 index.js 中的代码改成:

// index.js
const name = "axuebin"

执行 eslint 命令之后:
image.png
可以看到检查结果如下:

  • [no-unused-vars] 'name' is assigned a value but never used。定义了 name 变量却未使用。
  • [quotes] Strings must use singlequote。字符串必须使用单引号。
  • [semi] Missing semicolon。缺失分号。

老老实实地按照规范修改代码,使用单引号并将加上分号。当然,如果你们希望是双引号和不带分号,修改相应的配置即可。

具体各个规则如何配置可以查看:https://eslint.org/docs/rules

自动修复

执行 eslint xxx --fix 可以自动修复一些代码中的问题,将无法自动修复的问题暴露出来。比如上文中提到的引号和分号的问题,就可以通过 --fix 自动修复,而 no-unused-vars 变量未使用的问题,ESLint 就无法自动修复。
image.png

使用配置包

init 生成的配置文件中,我们看到包含这一行代码:

module.exports = {
  extends: "eslint:recommended"
}

这一行代码的意思是,使用 ESLint 的推荐配置。 extends: 'xxx' 就是 继承,当前的配置继承于 xxx 的配置,在此基础上进行扩展。

因此,我们也可以使用任意封装好的配置,可以在 NPM 上或者 GItHub 上搜索 eslint-config 关键词获取,本文我们将这类封装好的配置称作 “配置集”。比较常见的配置包有以下几个:

  • eslint-config-airbnb: Airbnb 公司提供的配置集
  • eslint-config-prettier: 使用这个配置集,会关闭一些可能与 Prettier 冲突的规则
  • eslint-config-react: create react app 使用的配置集
  • eslint-config-vue: vuejs 使用的配置集
  • ...

最佳实践

简单了解完 ESLint 之后,对于 ESLint 的更多使用细节以及原理,在本篇文章就不展开了,感兴趣的朋友可以在官网详细了解。本文重点还是在于如何在团队工程化体系中落地 ESLint,这里提几个最佳实践。

抽象配置集

对于独立开发者以及业务场景比较简单的小型团队而言,使用现成、完备的第三方配置集是非常高效的,可以较低成本低接入 ESLint 代码检查。

但是,对于中大型团队而言,在实际代码规范落地的过程中我们会发现,不可能存在一个能够完全符合团队风格的三方配置包,我们还是会在 extends 三方配置集的基础上,再手动在 rules 配置里加一些自定义的规则。时间长了,有可能 A 应用和 B 应用里的 rules 就不一样了,就很难达到统一的目的。

这时候,就需要一个中心化的方式来管理配置包:根据团队代码风格整理(或者基于现有的三方配置集)发布一个配置集,团队统一使用这个包,就可以做到中心化管理和更新

除此之外,从技术层面考虑,目前一个前端团队的面对的场景可能比较复杂。比如:

  • 技术选型不一致:框架上 PC 使用 React,H5 使用 Vue;是否使用 TypeScript
  • 跨端场景多:Web 端和小程序端,还有 Node
  • ...

以上问题在真实开发中都是存在的,所以在代码规范的工程化方案落地时,一个单一功能的配置集是不够用的,这时候还需要考虑这个配置集如何抽象。

为了解决以上问题,这里提供一种解决方案的思路:
image.png
具体拆解来看,就是有一个类似 eslint-config-standard 的基础规则集(包括代码风格、变量相关、ES6 语法等),在此基础之上集成社区的一些插件(Vue/React)等,封装成统一的一个 NPM Package 发布,消费时根据当前应用类型通过不同路径来 extends 对应的配置集。

这里有一个 Demo,感兴趣的朋友可以看一下:eslint-config-axuebin

开发插件

ESLint 提供了丰富的配置供开发者选择,但是在复杂的业务场景和特定的技术栈下,这些通用规则是不够用的。ESLint 通过插件的形式赋予了扩展性,开发者可以自定义任意的检查规则,比如 eslint-plugin-vue / eslint-plugin-react 就是 Vue / React 框架中使用的扩展插件,官网也提供了相关文档引导开发者开发一个插件。

一般来说,我们也不需要开发插件,但我们至少需要了解有这么个东西。在做一些团队代码质量检查的时候,我们可能会有一些特殊的业务逻辑,这时候 ESLint 插件是可以帮助我们做一些事情。

这里就不展开了,主要就是一些 AST 的用法,照着官方文档就可以上手,或者可以参考现有的一些插件写法。

脚手架 / CLI 工具

当有了团队的统一 ESLint 配置集和插件之后,我们会将它们集成到脚手架中,方便新项目集成和开箱即用。但是对于一些老项目,如果需要手动改造还是会有一些麻烦的,这时候就可以借助于 CLI 来完成一键升级。

本文结合上文的 Demo eslint-config-axuebin,设计一个简单的 CLI Demo。由于当前配置也比较简单,所以 CLI 只需要做几件简单的事情即可:

  • 询问用户当前项目的类型(是 JavaScript 还是 TypeScript、是 React 还是 Vue)
  • 根据项目类型写 .eslintrc.js 文件
  • 根据项目类型安装所需依赖(比如 vue 需要 eslint-plugin-vue)
  • package.json 的 scripts 中写入 "lint": "eslint src test --fix"  

核心代码如下:

const path = require('path');
const fs = require('fs');
const chalk = require('chalk');
const spawn = require('cross-spawn');

const { askForLanguage, askForFrame } = require('./ask');
const { eslintrcConfig, needDeps } = require('./config');

module.exports = async () => {
  const language = await askForLanguage();
  const frame = await askForFrame();

  let type = language;
  if (frame) {
    type += `/${frame}`;
  }

  fs.writeFileSync(
    path.join(process.cwd(), '.eslintrc.js'),
    `// Documentation\n// https://github.com/axuebin/eslint-config-axuebin\nmodule.exports = ${JSON.stringify(
      eslintrcConfig(type),
      null,
      2
    )}`
  );

  const deps = needDeps.javascript;
  if (language === 'typescript') {
    deps.concat(needDeps.typescript);
  }
  if (frame) {
    deps.concat(needDeps[frame]);
  }

  spawn.sync('npm', ['install', ...deps, '--save'], { stdio: 'inherit' });
};

可运行的 CLI Demo 代码见:axb-lint,在项目目录下执行:axblint eslint 即可,如图:
image.png

自动化

配置了 ESLint 之后,我们需要让开发者感知到 ESLint 的约束。开发者可以自己运行 eslint 命令来跑代码检查,这不够高效,所以我们需要一些自动化手段来做这个事情。当然 在开发时,编辑器也有提供相应的功能可以根据当前工作区下的 ESLint 配置文件来检查当前正在编辑的文件,这个不是我们关心的重点。

一般我们会在有以下几种方式做 ESLint 检查:

  • 开发时:依赖编辑器的能力
  • 手动运行:在终端中手动执行 eslint 命令
  • pre-commit:在提交 git 前自动执行 eslint 命令
  • ci:依赖 git 的持续集成,可以将检查结果输出文件上传到服务器

这里提一下 pre-commit 的方案,在每一次本地开发完成提交代码前就做 ESLint 检查,保证云端的代码是统一规范的。

这种方式非常简单,只需要在项目中依赖 huskylint-staged 即可完成。安装好依赖之后,在 package.json 文件加入以下配置即可:

{
  "lint-staged": {
    "*.{js,jsx,ts,tsx}": "eslint --cache --fix"
  },
  "husky": {
    "hooks": {
      "pre-commit": "lint-staged"
    }
  }
}

效果如图所示:
image.png
如果代码跑 ESLint 检查抛了 Error 错误,则会中断 commit 流程:
image.png
这样就可以确保提交到 GitHub 仓库上的代码是统一规范的。(当然,如果认为将这些配置文件都删了,那也是没办法的)

总结

本文介绍了 ESLint 在中小型前端团队的一些最佳实践的想法,大家可以在此基础上扩展,制订一套完善的 ESLint 工作流,落地到自己团队中。

本文是前端代码规范系列文章的其中一篇,后续还有关于 StyleLint/CommitLint/Prettier 等的文章,并且还有一篇完整的关于前端代码规范工程化实践的文章,敬请期待(也有可能就鸽了)。


更多原创文章欢迎关注公众号「玩相机的程序员」,或者加我微信 xb9207 交流

查看原文

前端荣耀 收藏了文章 · 4月12日

基于 qiankun 的微前端最佳实践(万字长文) - 从 0 到 1 篇

micro-app

写在开头

微前端系列文章:

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

计划如下:

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

引言

大家好~

本文是基于 qiankun 的微前端最佳实践系列文章之 从 0 到 1 篇,本文将分享如何使用 qiankun 如何搭建主应用基座,然后接入不同技术栈的微应用,完成微前端架构的从 0 到 1。

本教程采用 Vue 作为主应用基座,接入不同技术栈的微应用。如果你不懂 Vue 也没关系,我们在搭建主应用基座的教程尽量不涉及 VueAPI,涉及到 API 的地方都会给出解释。

注意:qiankun 属于无侵入性的微前端框架,对主应用基座和微应用的技术栈都没有要求。

我们在本教程中,接入了多技术栈 微应用主应用 最终效果图如下:

micro-app

构建主应用基座

我们以 实战案例 - feature-inject-sub-apps 分支 (案例是以 Vue 为基座的主应用,接入多个微应用) 为例,来介绍一下如何在 qiankun 中如何接入不同技术栈的微应用。

我们先使用 vue-cli 生成一个 Vue 的项目,初始化主应用。

vue-cliVue 官方提供的脚手架工具,用于快速搭建一个 Vue 项目。如果你想跳过这一步,可以直接 clone实战案例 - feature-inject-sub-apps 分支 的代码。

将普通的项目改造成 qiankun 主应用基座,需要进行三步操作:

  1. 创建微应用容器 - 用于承载微应用,渲染显示微应用;
  2. 注册微应用 - 设置微应用激活条件,微应用地址等等;
  3. 启动 qiankun

创建微应用容器

我们先在主应用中创建微应用的承载容器,这个容器规定了微应用的显示区域,微应用将在该容器内渲染并显示。

我们先设置路由,路由文件规定了主应用自身的路由匹配规则,代码实现如下:

// micro-app-main/src/routes/index.ts
import Home from "@/pages/home/index.vue";

const routes = [
  {
    /**
     * path: 路径为 / 时触发该路由规则
     * name: 路由的 name 为 Home
     * component: 触发路由时加载 `Home` 组件
     */
    path: "/",
    name: "Home",
    component: Home,
  },
];

export default routes;

// micro-app-main/src/main.ts
//...
import Vue from "vue";
import VueRouter from "vue-router";

import routes from "./routes";

/**
 * 注册路由实例
 * 即将开始监听 location 变化,触发路由规则
 */
const router = new VueRouter({
  mode: "history",
  routes,
});

// 创建 Vue 实例
// 该实例将挂载/渲染在 id 为 main-app 的节点上
new Vue({
  router,
  render: (h) => h(App),
}).$mount("#main-app");

从上面代码可以看出,我们设置了主应用的路由规则,设置了 Home 主页的路由匹配规则。

我们现在来设置主应用的布局,我们会有一个菜单和显示区域,代码实现如下:

// micro-app-main/src/App.vue
//...
export default class App extends Vue {
  /**
   * 菜单列表
   * key: 唯一 Key 值
   * title: 菜单标题
   * path: 菜单对应的路径
   */
  menus = [
    {
      key: "Home",
      title: "主页",
      path: "/",
    },
  ];
}

上面的代码是我们对菜单配置的实现,我们还需要实现基座和微应用的显示区域(如下图)

micro-app

我们来分析一下上面的代码:

  • 第 5 行:主应用菜单,用于渲染菜单;
  • 第 9 行:主应用渲染区。在触发主应用路由规则时(由路由配置表的 $route.name 判断),将渲染主应用的组件;
  • 第 10 行:微应用渲染区。在未触发主应用路由规则时(由路由配置表的 $route.name 判断),将渲染微应用节点;

从上面的分析可以看出,我们使用了在路由表配置的 name 字段进行判断,判断当前路由是否为主应用路由,最后决定渲染主应用组件或是微应用节点。

由于篇幅原因,样式实现代码就不贴出来了,最后主应用的实现效果如下图所示:

micro-app

从上图可以看出,我们主应用的组件和微应用是显示在同一片内容区域,根据路由规则决定渲染规则。

注册微应用

在构建好了主框架后,我们需要使用 qiankunregisterMicroApps 方法注册微应用,代码实现如下:

// micro-app-main/src/micro/apps.ts
// 此时我们还没有微应用,所以 apps 为空
const apps = [];

export default apps;

// micro-app-main/src/micro/index.ts
// 一个进度条插件
import NProgress from "nprogress";
import "nprogress/nprogress.css";
import { message } from "ant-design-vue";
import {
  registerMicroApps,
  addGlobalUncaughtErrorHandler,
  start,
} from "qiankun";

// 微应用注册信息
import apps from "./apps";

/**
 * 注册微应用
 * 第一个参数 - 微应用的注册信息
 * 第二个参数 - 全局生命周期钩子
 */
registerMicroApps(apps, {
  // qiankun 生命周期钩子 - 微应用加载前
  beforeLoad: (app: any) => {
    // 加载微应用前,加载进度条
    NProgress.start();
    console.log("before load", app.name);
    return Promise.resolve();
  },
  // qiankun 生命周期钩子 - 微应用挂载后
  afterMount: (app: any) => {
    // 加载微应用前,进度条加载完成
    NProgress.done();
    console.log("after mount", app.name);
    return Promise.resolve();
  },
});

/**
 * 添加全局的未捕获异常处理器
 */
addGlobalUncaughtErrorHandler((event: Event | string) => {
  console.error(event);
  const { message: msg } = event as any;
  // 加载失败时提示
  if (msg && msg.includes("died in status LOADING_SOURCE_CODE")) {
    message.error("微应用加载失败,请检查应用是否可运行");
  }
});

// 导出 qiankun 的启动函数
export default start;

从上面可以看出,我们的微应用注册信息在 apps 数组中(此时为空,我们在后面接入微应用时会添加微应用注册信息),然后使用 qiankunregisterMicroApps 方法注册微应用,最后导出了 start 函数,注册微应用的工作就完成啦!

启动主应用

我们在注册好了微应用,导出 start 函数后,我们需要在合适的地方调用 start 启动主应用。

我们一般是在入口文件启动 qiankun 主应用,代码实现如下:

// micro-app-main/src/main.ts
//...
import startQiankun from "./micro";

startQiankun();

最后,启动我们的主应用,效果图如下:

micro-app

因为我们还没有注册任何微应用,所以这里的效果图和上面的效果图是一样的。

到这一步,我们的主应用基座就创建好啦!

接入微应用

我们现在的主应用基座只有一个主页,现在我们需要接入微应用。

qiankun 内部通过 import-entry-html 加载微应用,要求微应用需要导出生命周期钩子函数(见下图)。

micro-app

从上图可以看出,qiankun 内部会校验微应用的生命周期钩子函数,如果微应用没有导出这三个生命周期钩子函数,则微应用会加载失败。

如果我们使用了脚手架搭建微应用的话,我们可以通过 webpack 配置在入口文件处导出这三个生命周期钩子函数。如果没有使用脚手架的话,也可以直接在微应用的 window 上挂载这三个生命周期钩子函数。

现在我们来接入我们的各个技术栈微应用吧!

注意,下面的内容对相关技术栈 API 不会再有过多介绍啦,如果你要接入不同技术栈的微应用,最好要对该技术栈有一些基础了解。

接入 Vue 微应用

我们以 实战案例 - feature-inject-sub-apps 分支 为例,我们在主应用的同级目录(micro-app-main 同级目录),使用 vue-cli 先创建一个 Vue 的项目,在命令行运行如下命令:

vue create micro-app-vue

本文的 vue-cli 选项如下图所示,你也可以根据自己的喜好选择配置。

micro-app

在新建项目完成后,我们创建几个路由页面再加上一些样式,最后效果如下:

micro-app

micro-app

注册微应用

在创建好了 Vue 微应用后,我们可以开始我们的接入工作了。首先我们需要在主应用中注册该微应用的信息,代码实现如下:

// micro-app-main/src/micro/apps.ts
const apps = [
  /**
   * name: 微应用名称 - 具有唯一性
   * entry: 微应用入口 - 通过该地址加载微应用
   * container: 微应用挂载节点 - 微应用加载完成后将挂载在该节点上
   * activeRule: 微应用触发的路由规则 - 触发路由规则后将加载该微应用
   */
  {
    name: "VueMicroApp",
    entry: "//localhost:10200",
    container: "#frame",
    activeRule: "/vue",
  },
];

export default apps;

通过上面的代码,我们就在主应用中注册了我们的 Vue 微应用,进入 /vue 路由时将加载我们的 Vue 微应用。

我们在菜单配置处也加入 Vue 微应用的快捷入口,代码实现如下:

// micro-app-main/src/App.vue
//...
export default class App extends Vue {
  /**
   * 菜单列表
   * key: 唯一 Key 值
   * title: 菜单标题
   * path: 菜单对应的路径
   */
  menus = [
    {
      key: "Home",
      title: "主页",
      path: "/",
    },
    {
      key: "VueMicroApp",
      title: "Vue 主页",
      path: "/vue",
    },
    {
      key: "VueMicroAppList",
      title: "Vue 列表页",
      path: "/vue/list",
    },
  ];
}

菜单配置完成后,我们的主应用基座效果图如下

micro-app

配置微应用

在主应用注册好了微应用后,我们还需要对微应用进行一系列的配置。首先,我们在 Vue 的入口文件 main.js 中,导出 qiankun 主应用所需要的三个生命周期钩子函数,代码实现如下:

micro-app

从上图来分析:

  • 第 6 行webpack 默认的 publicPath"" 空字符串,会基于当前路径来加载资源。我们在主应用中加载微应用时需要重新设置 publicPath,这样才能正确加载微应用的相关资源。(public-path.js 具体实现在后面)
  • 第 21 行:微应用的挂载函数,在主应用中运行时将在 mount 生命周期钩子函数中调用,可以保证在沙箱内运行。
  • 第 38 行:微应用独立运行时,直接执行 render 函数挂载微应用。
  • 第 46 行:微应用导出的生命周期钩子函数 - bootstrap
  • 第 53 行:微应用导出的生命周期钩子函数 - mount
  • 第 61 行:微应用导出的生命周期钩子函数 - unmount

完整代码实现如下:

// micro-app-vue/src/public-path.js
if (window.__POWERED_BY_QIANKUN__) {
  // 动态设置 webpack publicPath,防止资源加载出错
  // eslint-disable-next-line no-undef
  __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}

// micro-app-vue/src/main.js
import Vue from "vue";
import VueRouter from "vue-router";
import Antd from "ant-design-vue";
import "ant-design-vue/dist/antd.css";

import "./public-path";
import App from "./App.vue";
import routes from "./routes";

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

let instance = null;
let router = null;

/**
 * 渲染函数
 * 两种情况:主应用生命周期钩子中运行 / 微应用单独启动时运行
 */
function render() {
  // 在 render 中创建 VueRouter,可以保证在卸载微应用时,移除 location 事件监听,防止事件污染
  router = new VueRouter({
    // 运行在主应用中时,添加路由命名空间 /vue
    base: window.__POWERED_BY_QIANKUN__ ? "/vue" : "/",
    mode: "history",
    routes,
  });

  // 挂载应用
  instance = new Vue({
    router,
    render: (h) => h(App),
  }).$mount("#app");
}

// 独立运行时,直接挂载应用
if (!window.__POWERED_BY_QIANKUN__) {
  render();
}

/**
 * bootstrap 只会在微应用初始化的时候调用一次,下次微应用重新进入时会直接调用 mount 钩子,不会再重复触发 bootstrap。
 * 通常我们可以在这里做一些全局变量的初始化,比如不会在 unmount 阶段被销毁的应用级别的缓存等。
 */
export async function bootstrap() {
  console.log("VueMicroApp bootstraped");
}

/**
 * 应用每次进入都会调用 mount 方法,通常我们在这里触发应用的渲染方法
 */
export async function mount(props) {
  console.log("VueMicroApp mount", props);
  render(props);
}

/**
 * 应用每次 切出/卸载 会调用的方法,通常在这里我们会卸载微应用的应用实例
 */
export async function unmount() {
  console.log("VueMicroApp unmount");
  instance.$destroy();
  instance = null;
  router = null;
}

在配置好了入口文件 main.js 后,我们还需要配置 webpack,使 main.js 导出的生命周期钩子函数可以被 qiankun 识别获取。

我们直接配置 vue.config.js 即可,代码实现如下:

// micro-app-vue/vue.config.js
const path = require("path");

module.exports = {
  devServer: {
    // 监听端口
    port: 10200,
    // 关闭主机检查,使微应用可以被 fetch
    disableHostCheck: true,
    // 配置跨域请求头,解决开发环境的跨域问题
    headers: {
      "Access-Control-Allow-Origin": "*",
    },
  },
  configureWebpack: {
    resolve: {
      alias: {
        "@": path.resolve(__dirname, "src"),
      },
    },
    output: {
      // 微应用的包名,这里与主应用中注册的微应用名称一致
      library: "VueMicroApp",
      // 将你的 library 暴露为所有的模块定义下都可运行的方式
      libraryTarget: "umd",
      // 按需加载相关,设置为 webpackJsonp_VueMicroApp 即可
      jsonpFunction: `webpackJsonp_VueMicroApp`,
    },
  },
};

我们需要重点关注一下 output 选项,当我们把 libraryTarget 设置为 umd 后,我们的 library 就暴露为所有的模块定义下都可运行的方式了,主应用就可以获取到微应用的生命周期钩子函数了。

vue.config.js 修改完成后,我们重新启动 Vue 微应用,然后打开主应用基座 http://localhost:9999。我们点击左侧菜单切换到微应用,此时我们的 Vue 微应用被正确加载啦!(见下图)

micro-app

此时我们打开控制台,可以看到我们所执行的生命周期钩子函数(见下图)

micro-app

到这里,Vue 微应用就接入成功了!

接入 React 微应用

我们以 实战案例 - feature-inject-sub-apps 分支 为例,我们在主应用的同级目录(micro-app-main 同级目录),使用 react-create-app 先创建一个 React 的项目,在命令行运行如下命令:

npx create-react-app micro-app-react

在项目创建完成后,我们在根目录下添加 .env 文件,设置项目监听的端口,代码实现如下:

# micro-app-react/.env
PORT=10100
BROWSER=none

然后,我们创建几个路由页面再加上一些样式,最后效果如下:

micro-app

micro-app

注册微应用

在创建好了 React 微应用后,我们可以开始我们的接入工作了。首先我们需要在主应用中注册该微应用的信息,代码实现如下:

// micro-app-main/src/micro/apps.ts
const apps = [
  /**
   * name: 微应用名称 - 具有唯一性
   * entry: 微应用入口 - 通过该地址加载微应用
   * container: 微应用挂载节点 - 微应用加载完成后将挂载在该节点上
   * activeRule: 微应用触发的路由规则 - 触发路由规则后将加载该微应用
   */
  {
    name: "ReactMicroApp",
    entry: "//localhost:10100",
    container: "#frame",
    activeRule: "/react",
  },
];

export default apps;

通过上面的代码,我们就在主应用中注册了我们的 React 微应用,进入 /react 路由时将加载我们的 React 微应用。

我们在菜单配置处也加入 React 微应用的快捷入口,代码实现如下:

// micro-app-main/src/App.vue
//...
export default class App extends Vue {
  /**
   * 菜单列表
   * key: 唯一 Key 值
   * title: 菜单标题
   * path: 菜单对应的路径
   */
  menus = [
    {
      key: "Home",
      title: "主页",
      path: "/",
    },
    {
      key: "ReactMicroApp",
      title: "React 主页",
      path: "/react",
    },
    {
      key: "ReactMicroAppList",
      title: "React 列表页",
      path: "/react/list",
    },
  ];
}

菜单配置完成后,我们的主应用基座效果图如下

micro-app

配置微应用

在主应用注册好了微应用后,我们还需要对微应用进行一系列的配置。首先,我们在 React 的入口文件 index.js 中,导出 qiankun 主应用所需要的三个生命周期钩子函数,代码实现如下:

micro-app

从上图来分析:

  • 第 5 行webpack 默认的 publicPath"" 空字符串,会基于当前路径来加载资源。我们在主应用中加载微应用时需要重新设置 publicPath,这样才能正确加载微应用的相关资源。(public-path.js 具体实现在后面)
  • 第 12 行:微应用的挂载函数,在主应用中运行时将在 mount 生命周期钩子函数中调用,可以保证在沙箱内运行。
  • 第 17 行:微应用独立运行时,直接执行 render 函数挂载微应用。
  • 第 25 行:微应用导出的生命周期钩子函数 - bootstrap
  • 第 32 行:微应用导出的生命周期钩子函数 - mount
  • 第 40 行:微应用导出的生命周期钩子函数 - unmount

完整代码实现如下:

// micro-app-react/src/public-path.js
if (window.__POWERED_BY_QIANKUN__) {
  // 动态设置 webpack publicPath,防止资源加载出错
  // eslint-disable-next-line no-undef
  __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}

// micro-app-react/src/index.js
import React from "react";
import ReactDOM from "react-dom";
import "antd/dist/antd.css";

import "./public-path";
import App from "./App.jsx";

/**
 * 渲染函数
 * 两种情况:主应用生命周期钩子中运行 / 微应用单独启动时运行
 */
function render() {
  ReactDOM.render(<App />, document.getElementById("root"));
}

// 独立运行时,直接挂载应用
if (!window.__POWERED_BY_QIANKUN__) {
  render();
}

/**
 * bootstrap 只会在微应用初始化的时候调用一次,下次微应用重新进入时会直接调用 mount 钩子,不会再重复触发 bootstrap。
 * 通常我们可以在这里做一些全局变量的初始化,比如不会在 unmount 阶段被销毁的应用级别的缓存等。
 */
export async function bootstrap() {
  console.log("ReactMicroApp bootstraped");
}

/**
 * 应用每次进入都会调用 mount 方法,通常我们在这里触发应用的渲染方法
 */
export async function mount(props) {
  console.log("ReactMicroApp mount", props);
  render(props);
}

/**
 * 应用每次 切出/卸载 会调用的方法,通常在这里我们会卸载微应用的应用实例
 */
export async function unmount() {
  console.log("ReactMicroApp unmount");
  ReactDOM.unmountComponentAtNode(document.getElementById("root"));
}

在配置好了入口文件 index.js 后,我们还需要配置路由命名空间,以确保主应用可以正确加载微应用,代码实现如下:

// micro-app-react/src/App.jsx
const BASE_NAME = window.__POWERED_BY_QIANKUN__ ? "/react" : "";
const App = () => {
  //...

  return (
    // 设置路由命名空间
    <Router basename={BASE_NAME}>{/* ... */}</Router>
  );
};

接下来,我们还需要配置 webpack,使 index.js 导出的生命周期钩子函数可以被 qiankun 识别获取。

我们需要借助 react-app-rewired 来帮助我们修改 webpack 的配置,我们直接安装该插件:

npm install react-app-rewired -D

react-app-rewired 安装完成后,我们还需要修改 package.jsonscripts 选项,修改为由 react-app-rewired 启动应用,就像下面这样

// micro-app-react/package.json

//...
"scripts": {
  "start": "react-app-rewired start",
  "build": "react-app-rewired build",
  "test": "react-app-rewired test",
  "eject": "react-app-rewired eject"
}

react-app-rewired 配置完成后,我们新建 config-overrides.js 文件来配置 webpack,代码实现如下:

const path = require("path");

module.exports = {
  webpack: (config) => {
    // 微应用的包名,这里与主应用中注册的微应用名称一致
    config.output.library = `ReactMicroApp`;
    // 将你的 library 暴露为所有的模块定义下都可运行的方式
    config.output.libraryTarget = "umd";
    // 按需加载相关,设置为 webpackJsonp_VueMicroApp 即可
    config.output.jsonpFunction = `webpackJsonp_ReactMicroApp`;

    config.resolve.alias = {
      ...config.resolve.alias,
      "@": path.resolve(__dirname, "src"),
    };
    return config;
  },

  devServer: function (configFunction) {
    return function (proxy, allowedHost) {
      const config = configFunction(proxy, allowedHost);
      // 关闭主机检查,使微应用可以被 fetch
      config.disableHostCheck = true;
      // 配置跨域请求头,解决开发环境的跨域问题
      config.headers = {
        "Access-Control-Allow-Origin": "*",
      };
      // 配置 history 模式
      config.historyApiFallback = true;

      return config;
    };
  },
};

我们需要重点关注一下 output 选项,当我们把 libraryTarget 设置为 umd 后,我们的 library 就暴露为所有的模块定义下都可运行的方式了,主应用就可以获取到微应用的生命周期钩子函数了。

config-overrides.js 修改完成后,我们重新启动 React 微应用,然后打开主应用基座 http://localhost:9999。我们点击左侧菜单切换到微应用,此时我们的 React 微应用被正确加载啦!(见下图)

micro-app

此时我们打开控制台,可以看到我们所执行的生命周期钩子函数(见下图)

micro-app

到这里,React 微应用就接入成功了!

接入 Angular 微应用

Angularqiankun 目前的兼容性并不太好,接入 Angular 微应用需要一定的耐心与技巧。

对于选择 Angular 技术栈的前端开发来说,对这类情况应该驾轻就熟(没有办法)。

我们以 实战案例 - feature-inject-sub-apps 分支 为例,我们在主应用的同级目录(micro-app-main 同级目录),使用 @angular/cli 先创建一个 Angular 的项目,在命令行运行如下命令:

ng new micro-app-angular

本文的 @angular/cli 选项如下图所示,你也可以根据自己的喜好选择配置。

micro-app

然后,我们创建几个路由页面再加上一些样式,最后效果如下:

micro-app

micro-app

注册微应用

在创建好了 Angular 微应用后,我们可以开始我们的接入工作了。首先我们需要在主应用中注册该微应用的信息,代码实现如下:

// micro-app-main/src/micro/apps.ts
const apps = [
  /**
   * name: 微应用名称 - 具有唯一性
   * entry: 微应用入口 - 通过该地址加载微应用
   * container: 微应用挂载节点 - 微应用加载完成后将挂载在该节点上
   * activeRule: 微应用触发的路由规则 - 触发路由规则后将加载该微应用
   */
  {
    name: "AngularMicroApp",
    entry: "//localhost:10300",
    container: "#frame",
    activeRule: "/angular",
  },
];

export default apps;

通过上面的代码,我们就在主应用中注册了我们的 Angular 微应用,进入 /angular 路由时将加载我们的 Angular 微应用。

我们在菜单配置处也加入 Angular 微应用的快捷入口,代码实现如下:

// micro-app-main/src/App.vue
//...
export default class App extends Vue {
  /**
   * 菜单列表
   * key: 唯一 Key 值
   * title: 菜单标题
   * path: 菜单对应的路径
   */
  menus = [
    {
      key: "Home",
      title: "主页",
      path: "/",
    },
    {
      key: "AngularMicroApp",
      title: "Angular 主页",
      path: "/angular",
    },
    {
      key: "AngularMicroAppList",
      title: "Angular 列表页",
      path: "/angular/list",
    },
  ];
}

菜单配置完成后,我们的主应用基座效果图如下

micro-app

最后我们在主应用的入口文件,引入 zone.js,代码实现如下:

Angular 运行依赖于 zone.js

qiankun 基于 single-spa 实现,single-spa 明确指出一个项目的 zone.js 只能存在一份实例,所以我们在主应用注入 zone.js

// micro-app-main/src/main.js

// 为 Angular 微应用所做的 zone 包注入
import "zone.js/dist/zone";

配置微应用

在主应用的工作完成后,我们还需要对微应用进行一系列的配置。首先,我们使用 single-spa-angular 生成一套配置,在命令行运行以下命令:

# 安装 single-spa
yarn add single-spa -S

# 添加 single-spa-angular
ng add single-spa-angular

运行命令时,根据自己的需求选择配置即可,本文配置如下:

micro-app

在生成 single-spa 配置后,我们需要进行一些 qiankun 的接入配置。我们在 Angular 微应用的入口文件 main.single-spa.ts 中,导出 qiankun 主应用所需要的三个生命周期钩子函数,代码实现如下:

micro-app

从上图来分析:

  • 第 21 行:微应用独立运行时,直接执行挂载函数挂载微应用。
  • 第 46 行:微应用导出的生命周期钩子函数 - bootstrap
  • 第 50 行:微应用导出的生命周期钩子函数 - mount
  • 第 54 行:微应用导出的生命周期钩子函数 - unmount

完整代码实现如下:

// micro-app-angular/src/main.single-spa.ts
import { enableProdMode, NgZone } from "@angular/core";

import { platformBrowserDynamic } from "@angular/platform-browser-dynamic";
import { Router } from "@angular/router";
import { ɵAnimationEngine as AnimationEngine } from "@angular/animations/browser";

import {
  singleSpaAngular,
  getSingleSpaExtraProviders,
} from "single-spa-angular";

import { AppModule } from "./app/app.module";
import { environment } from "./environments/environment";
import { singleSpaPropsSubject } from "./single-spa/single-spa-props";

if (environment.production) {
  enableProdMode();
}

// 微应用单独启动时运行
if (!(window as any).__POWERED_BY_QIANKUN__) {
  platformBrowserDynamic()
    .bootstrapModule(AppModule)
    .catch((err) => console.error(err));
}

const { bootstrap, mount, unmount } = singleSpaAngular({
  bootstrapFunction: (singleSpaProps) => {
    singleSpaPropsSubject.next(singleSpaProps);
    return platformBrowserDynamic(getSingleSpaExtraProviders()).bootstrapModule(
      AppModule
    );
  },
  template: "<app-root />",
  Router,
  NgZone,
  AnimationEngine,
});

/** 主应用生命周期钩子中运行 */
export {
  /**
   * bootstrap 只会在微应用初始化的时候调用一次,下次微应用重新进入时会直接调用 mount 钩子,不会再重复触发 bootstrap。
   * 通常我们可以在这里做一些全局变量的初始化,比如不会在 unmount 阶段被销毁的应用级别的缓存等。
   */
  bootstrap,
  /**
   * 应用每次进入都会调用 mount 方法,通常我们在这里触发应用的渲染方法
   */
  mount,
  /**
   * 应用每次 切出/卸载 会调用的方法,通常在这里我们会卸载微应用的应用实例
   */
  unmount,
};

在配置好了入口文件 main.single-spa.ts 后,我们还需要配置 webpack,使 main.single-spa.ts 导出的生命周期钩子函数可以被 qiankun 识别获取。

我们直接配置 extra-webpack.config.js 即可,代码实现如下:

// micro-app-angular/extra-webpack.config.js
const singleSpaAngularWebpack = require("single-spa-angular/lib/webpack")
  .default;
const webpackMerge = require("webpack-merge");

module.exports = (angularWebpackConfig, options) => {
  const singleSpaWebpackConfig = singleSpaAngularWebpack(
    angularWebpackConfig,
    options
  );

  const singleSpaConfig = {
    output: {
      // 微应用的包名,这里与主应用中注册的微应用名称一致
      library: "AngularMicroApp",
      // 将你的 library 暴露为所有的模块定义下都可运行的方式
      libraryTarget: "umd",
    },
  };
  const mergedConfig = webpackMerge.smart(
    singleSpaWebpackConfig,
    singleSpaConfig
  );
  return mergedConfig;
};

我们需要重点关注一下 output 选项,当我们把 libraryTarget 设置为 umd 后,我们的 library 就暴露为所有的模块定义下都可运行的方式了,主应用就可以获取到微应用的生命周期钩子函数了。

extra-webpack.config.js 修改完成后,我们还需要修改一下 package.json 中的启动命令,修改如下:

// micro-app-angular/package.json
{
  //...
  "script": {
    //...
    // --disable-host-check: 关闭主机检查,使微应用可以被 fetch
    // --port: 监听端口
    // --base-href: 站点的起始路径,与主应用中配置的一致
    "start": "ng serve --disable-host-check --port 10300 --base-href /angular"
  }
}

修改完成后,我们重新启动 Angular 微应用,然后打开主应用基座 http://localhost:9999。我们点击左侧菜单切换到微应用,此时我们的 Angular 微应用被正确加载啦!(见下图)

micro-app

到这里,Angular 微应用就接入成功了!

接入 Jquery、xxx... 微应用

这里的 Jquery、xxx... 微应用指的是没有使用脚手架,直接采用 html + css + js 三剑客开发的应用。

本案例使用了一些高级 ES 语法,请使用谷歌浏览器运行查看效果。

我们以 实战案例 - feature-inject-sub-apps 分支 为例,我们在主应用的同级目录(micro-app-main 同级目录),手动创建目录 micro-app-static

我们使用 express 作为服务器加载静态 html,我们先编辑 package.json,设置启动命令和相关依赖。

// micro-app-static/package.json
{
  "name": "micro-app-jquery",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "start": "nodemon index.js"
  },
  "author": "",
  "license": "ISC",
  "dependencies": {
    "express": "^4.17.1",
    "cors": "^2.8.5"
  },
  "devDependencies": {
    "nodemon": "^2.0.2"
  }
}

然后添加入口文件 index.js,代码实现如下:

// micro-app-static/index.js
const express = require("express");
const cors = require("cors");

const app = express();
// 解决跨域问题
app.use(cors());
app.use('/', express.static('static'));

// 监听端口
app.listen(10400, () => {
  console.log("server is listening in http://localhost:10400")
});

使用 npm install 安装相关依赖后,我们使用 npm start 启动应用。

我们新建 static 文件夹,在文件夹内新增一个静态页面 index.html(代码在后面会贴出),加上一些样式后,打开浏览器,最后效果如下:

micro-app

注册微应用

在创建好了 Static 微应用后,我们可以开始我们的接入工作了。首先我们需要在主应用中注册该微应用的信息,代码实现如下:

// micro-app-main/src/micro/apps.ts
const apps = [
  /**
   * name: 微应用名称 - 具有唯一性
   * entry: 微应用入口 - 通过该地址加载微应用
   * container: 微应用挂载节点 - 微应用加载完成后将挂载在该节点上
   * activeRule: 微应用触发的路由规则 - 触发路由规则后将加载该微应用
   */
  {
    name: "StaticMicroApp",
    entry: "//localhost:10400",
    container: "#frame",
    activeRule: "/static"
  },
];

export default apps;

通过上面的代码,我们就在主应用中注册了我们的 Static 微应用,进入 /static 路由时将加载我们的 Static 微应用。

我们在菜单配置处也加入 Static 微应用的快捷入口,代码实现如下:

// micro-app-main/src/App.vue
//...
export default class App extends Vue {
  /**
   * 菜单列表
   * key: 唯一 Key 值
   * title: 菜单标题
   * path: 菜单对应的路径
   */
  menus = [
    {
      key: "Home",
      title: "主页",
      path: "/"
    },
    {
      key: "StaticMicroApp",
      title: "Static 微应用",
      path: "/static"
    }
  ];
}

菜单配置完成后,我们的主应用基座效果图如下

micro-app

配置微应用

在主应用注册好了微应用后,我们还需要直接写微应用 index.html 的代码即可,代码实现如下:

micro-app

从上图来分析:

  • 第 70 行:微应用的挂载函数,在主应用中运行时将在 mount 生命周期钩子函数中调用,可以保证在沙箱内运行。
  • 第 77 行:微应用独立运行时,直接执行 render 函数挂载微应用。
  • 第 88 行:微应用注册的生命周期钩子函数 - bootstrap
  • 第 95 行:微应用注册的生命周期钩子函数 - mount
  • 第 102 行:微应用注册的生命周期钩子函数 - unmount

完整代码实现如下:

<!-- micro-app-static/static/index.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" />
    <!-- 引入 bootstrap -->
    <link
      href="https://cdn.bootcdn.net/ajax/libs/twitter-bootstrap/4.4.1/css/bootstrap.min.css"
      rel="stylesheet"
    />
    <title>Jquery App</title>
  </head>

  <body>
    <section
      id="jquery-app-container"
      style="padding: 20px; color: blue;"
    ></section>
  </body>
  <!-- 引入 jquery -->
  <script data-original="https://cdn.bootcdn.net/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
  <script>
    /**
     * 请求接口数据,构建 HTML
     */
    async function buildHTML() {
      const result = await fetch("http://dev-api.jt-gmall.com/mall", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        // graphql 的查询风格
        body: JSON.stringify({
          query: `{ vegetableList (page: 1, pageSize: 20) { page, pageSize, total, items { _id, name, poster, price } } }`,
        }),
      }).then((res) => res.json());
      const list = result.data.vegetableList.items;
      const html = `<table class="table">
  <thead>
    <tr>
      <th scope="col">菜名</th>
      <th scope="col">图片</th>
      <th scope="col">报价</th>
    </tr>
  </thead>
  <tbody>
    ${list
      .map(
        (item) => `
    <tr>
      <td>
        <img style="width: 40px; height: 40px; border-radius: 100%;" data-original="${item.poster}"></img>
      </td>
      <td>${item.name}</td>
      <td>¥ ${item.price}</td>
    </tr>
      `
      )
      .join("")}
  </tbody>
</table>`;
      return html;
    }

    /**
     * 渲染函数
     * 两种情况:主应用生命周期钩子中运行 / 微应用单独启动时运行
     */
    const render = async ($) => {
      const html = await buildHTML();
      $("#jquery-app-container").html(html);
      return Promise.resolve();
    };

    // 独立运行时,直接挂载应用
    if (!window.__POWERED_BY_QIANKUN__) {
      render($);
    }

    ((global) => {
      /**
       * 注册微应用生命周期钩子函数
       * global[appName] 中的 appName 与主应用中注册的微应用名称一致
       */
      global["StaticMicroApp"] = {
        /**
         * bootstrap 只会在微应用初始化的时候调用一次,下次微应用重新进入时会直接调用 mount 钩子,不会再重复触发 bootstrap。
         * 通常我们可以在这里做一些全局变量的初始化,比如不会在 unmount 阶段被销毁的应用级别的缓存等。
         */
        bootstrap: () => {
          console.log("MicroJqueryApp bootstraped");
          return Promise.resolve();
        },
        /**
         * 应用每次进入都会调用 mount 方法,通常我们在这里触发应用的渲染方法
         */
        mount: () => {
          console.log("MicroJqueryApp mount");
          return render($);
        },
        /**
         * 应用每次 切出/卸载 会调用的方法,通常在这里我们会卸载微应用的应用实例
         */
        unmount: () => {
          console.log("MicroJqueryApp unmount");
          return Promise.resolve();
        },
      };
    })(window);
  </script>
</html>

在构建好了 Static 微应用后,我们打开主应用基座 http://localhost:9999。我们点击左侧菜单切换到微应用,此时可以看到,我们的 Static 微应用被正确加载啦!(见下图)

micro-app

此时我们打开控制台,可以看到我们所执行的生命周期钩子函数(见下图)

micro-app

到这里,Static 微应用就接入成功了!

扩展阅读

如果在 Static 微应用的 html 中注入 SPA 路由功能的话,将演变成单页应用,只需要在主应用中注册一次。

如果是多个 html 的多页应用 - MPA,则需要在服务器(或反向代理服务器)中通过 referer 头返回对应的 html 文件,或者在主应用中注册多个微应用(不推荐)。

小结

最后,我们所有微应用都注册在主应用和主应用的菜单中,效果图如下:

micro-app

从上图可以看出,我们把不同技术栈 Vue、React、Angular、Jquery... 的微应用都已经接入到主应用基座中啦!

最后一件事

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

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

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

personal

查看原文

前端荣耀 关注了用户 · 4月12日

晒兜斯 @shadows_5b811ac2449a4

关注 119

前端荣耀 收藏了文章 · 4月12日

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

写在开头

微前端系列文章:

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

计划如下:

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

引言

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

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

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

qiankun

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

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

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

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

qiankun

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

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

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

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

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

qiankun

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

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

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

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

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

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

qiankun

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

qiankun

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

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

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

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

qiankun

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

qiankun

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

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

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

qiankun

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

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

qiankun

qiankun

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

主应用挂载子应用 HTML 模板

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

qiankun

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

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

qiankun

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

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

沙箱运行环境 - genSandbox

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

qiankun

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

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

qiankun

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

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

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

LegacySandbox

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

qiankun

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

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

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

qiankun

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

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

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

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

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

qiankun

从上图可以看出:

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

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

qiankun

多实例沙箱 - ProxySandbox

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

qiankun

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

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

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

qiankun

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

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

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

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

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

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

qiankun

SnapshotSandbox

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

qiankun

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

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

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

qiankun

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

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

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

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

qiankun

挂载沙箱 - mountSandbox

qiankun

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

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

qiankun

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

qiankun

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

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

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

计时器劫持 - patchTimer

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

qiankun

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

qiankun

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

qiankun

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

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

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

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

qiankun

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

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

qiankun

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

qiankun

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

qiankun

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

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

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

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

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

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

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

qiankun

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

卸载沙箱 - unmountSandbox

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

qiankun

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

qiankun

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

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

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

qiankun

注册内部生命周期函数

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

qiankun

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

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

qiankun

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

qiankun

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

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

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

qiankun

触发 beforeLoad 生命周期钩子函数

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

qiankun

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

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

qiankun

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

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

qiankun

进入到 mount 挂载流程

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

qiankun

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

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

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

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

qiankun

我们进行逐行解析:

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

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

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

qiankun

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

qiankun

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

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

qiankun

进入到 unmount 卸载流程

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

qiankun

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

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

qiankun

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

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

qiankun

总结

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

qiankun

彩蛋

qiankun

展望

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

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

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

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

架构图

最后一件事

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

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

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

personal

查看原文

前端荣耀 赞了文章 · 4月12日

Vue3+TypeScript封装axios并进行请求调用

 不是吧,不是吧,原来真的有人都2021年了,连TypeScript都没听说过吧?在项目中使用TypeScript虽然短期内会增加一些开发成本,但是对于其需要长期维护的项目,TypeScript能够减少其维护成本,使用TypeScript增加了代码的可读性和可维护性,且拥有较为活跃的社区,当居为大前端的趋势所在,那就开始淦起来吧~

使用TypeScript封装基础axios库

代码如下:

// http.ts
import axios, { AxiosRequestConfig, AxiosResponse } from 'axios'
import { ElMessage } from "element-plus"

const showStatus = (status: number) => {
  let message = ''
  switch (status) {
    case 400:
      message = '请求错误(400)'
      break
    case 401:
      message = '未授权,请重新登录(401)'
      break
    case 403:
      message = '拒绝访问(403)'
      break
    case 404:
      message = '请求出错(404)'
      break
    case 408:
      message = '请求超时(408)'
      break
    case 500:
      message = '服务器错误(500)'
      break
    case 501:
      message = '服务未实现(501)'
      break
    case 502:
      message = '网络错误(502)'
      break
    case 503:
      message = '服务不可用(503)'
      break
    case 504:
      message = '网络超时(504)'
      break
    case 505:
      message = 'HTTP版本不受支持(505)'
      break
    default:
      message = `连接出错(${status})!`
  }
  return `${message},请检查网络或联系管理员!`
}

const service = axios.create({
  // 联调
  // baseURL: process.env.NODE_ENV === 'production' ? `/` : '/api',
  baseURL: "/api",
  headers: {
    get: {
      'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8'
    },
    post: {
      'Content-Type': 'application/json;charset=utf-8'
    }
  },
  // 是否跨站点访问控制请求
  withCredentials: true,
  timeout: 30000,
  transformRequest: [(data) => {
    data = JSON.stringify(data)
    return data
  }],
  validateStatus() {
    // 使用async-await,处理reject情况较为繁琐,所以全部返回resolve,在业务代码中处理异常
    return true
  },
  transformResponse: [(data) => {
    if (typeof data === 'string' && data.startsWith('{')) {
      data = JSON.parse(data)
    }
    return data
  }]
  
})

// 请求拦截器
service.interceptors.request.use((config: AxiosRequestConfig) => {
  //获取token,并将其添加至请求头中
  let token = localStorage.getItem('token')
  if(token){
    config.headers.Authorization = `${token}`;
  }
  return config
}, (error) => {
  // 错误抛到业务代码
  error.data = {}
  error.data.msg = '服务器异常,请联系管理员!'
  return Promise.resolve(error)
})

// 响应拦截器
service.interceptors.response.use((response: AxiosResponse) => {
  const status = response.status
  let msg = ''
  if (status < 200 || status >= 300) {
    // 处理http错误,抛到业务代码
    msg = showStatus(status)
    if (typeof response.data === 'string') {
      response.data = { msg }
    } else {
      response.data.msg = msg
    }
  }
  return response
}, (error) => {
  if (axios.isCancel(error)) {
    console.log('repeated request: ' + error.message)
  } else {
    // handle error code
    // 错误抛到业务代码
    error.data = {}
    error.data.msg = '请求超时或服务器异常,请检查网络或联系管理员!'
    ElMessage.error(error.data.msg)
  }
  return Promise.reject(error)
})

export default service

取消多次重复的请求版本

 在上述代码加入如下代码:

// http.ts
import axios, { AxiosRequestConfig, AxiosResponse } from 'axios'
import qs from "qs"
import { ElMessage } from "element-plus"

// 声明一个 Map 用于存储每个请求的标识 和 取消函数
const pending = new Map()
/**
 * 添加请求
 * @param {Object} config 
 */
const addPending = (config: AxiosRequestConfig) => {
  const url = [
    config.method,
    config.url,
    qs.stringify(config.params),
    qs.stringify(config.data)
  ].join('&')
  config.cancelToken = config.cancelToken || new axios.CancelToken(cancel => {
    if (!pending.has(url)) { // 如果 pending 中不存在当前请求,则添加进去
      pending.set(url, cancel)
    }
  })
}
/**
 * 移除请求
 * @param {Object} config 
 */
const removePending = (config: AxiosRequestConfig) => {
  const url = [
    config.method,
    config.url,
    qs.stringify(config.params),
    qs.stringify(config.data)
  ].join('&')
  if (pending.has(url)) { // 如果在 pending 中存在当前请求标识,需要取消当前请求,并且移除
    const cancel = pending.get(url)
    cancel(url)
    pending.delete(url)
  }
}

/**
 * 清空 pending 中的请求(在路由跳转时调用)
 */
export const clearPending = () => {
  for (const [url, cancel] of pending) {
    cancel(url)
  }
  pending.clear()
}

// 请求拦截器
service.interceptors.request.use((config: AxiosRequestConfig) => {
  removePending(config) // 在请求开始前,对之前的请求做检查取消操作
  addPending(config) // 将当前请求添加到 pending 中
  let token = localStorage.getItem('token')
  if(token){
    config.headers.Authorization = `${token}`;
  }
  return config
}, (error) => {
  // 错误抛到业务代码
  error.data = {}
  error.data.msg = '服务器异常,请联系管理员!'
  return Promise.resolve(error)
})

// 响应拦截器
service.interceptors.response.use((response: AxiosResponse) => {

  removePending(response) // 在请求结束后,移除本次请求
  const status = response.status
  let msg = ''
  if (status < 200 || status >= 300) {
    // 处理http错误,抛到业务代码
    msg = showStatus(status)
    if (typeof response.data === 'string') {
      response.data = { msg }
    } else {
      response.data.msg = msg
    }
  }

  return response
}, (error) => {
  if (axios.isCancel(error)) {
    console.log('repeated request: ' + error.message)
  } else {
    // handle error code
    // 错误抛到业务代码
    error.data = {}
    error.data.msg = '请求超时或服务器异常,请检查网络或联系管理员!'
    ElMessage.error(error.data.msg)
  }
  return Promise.reject(error)
})

export default service

在路由跳转时撤销所有请求

 在路由文件index.ts中加入

import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'
import Login from '@/views/Login/Login.vue'
//引入在axios暴露出的clearPending函数
import { clearPending } from "@/api/axios"

....
....
....

const router = createRouter({
  history: createWebHistory(process.env.BASE_URL),
  routes
})

router.beforeEach((to, from, next) => {
  //在跳转路由之前,先清除所有的请求
  clearPending()
  // ...
  next()
})

export default router

使用封装的axios请求库

封装响应格式

// 接口响应通过格式
export interface HttpResponse {
  status: number
  statusText: string
  data: {
    code: number
    desc: string
    [key: string]: any
  }
}

封装接口方法

 举个栗子,进行封装User接口,代码如下~

import Axios from './axios'
import { HttpResponse } from '@/@types'
/**
 * @interface loginParams -登录参数
 * @property {string} username -用户名
 * @property {string} password -用户密码
 */
interface LoginParams {
  username: string
  password: string
}
//封装User类型的接口方法
export class UserService {
  /**
   * @description 查询User的信息
   * @param {number} teamId - 所要查询的团队ID
   * @return {HttpResponse} result
   */
  static async login(params: LoginParams): Promise<HttpResponse> {
    return Axios('/api/user', {
      method: 'get',
      responseType: 'json',
      params: {
        ...params
      },
    })
  }

  static async resgister(params: LoginParams): Promise<HttpResponse> {
    return Axios('/api/user/resgister', {
      method: 'get',
      responseType: 'json',
      params: {
        ...params
      },
    })
  }
}

项目中进行使用

 代码如下:

<template>
     <input type="text" v-model="Account" placeholder="请输入账号" name="username" >
     <input type="text" v-model="Password" placeholder="请输入密码" name="username" >
     <button @click.prevent="handleRegister()">登录</button>
</template>
<script lang="ts">
import { defineComponent, reactive, toRefs } from 'vue'
//引入接口
import { UserService } from '@/api/user'

export default defineComponent({
  setup() {
    const state = reactive({
      Account: 'admin', //账户
      Password: 'hhhh', //密码
    })

    const handleLogin = async () => {
      const loginParams = {
        username: state.Account,
        password: state.Password,
      }
      const res = await UserService.login(loginParams)
       console.log(res)
    }

    const handleRegister = async () => {
      const loginParams = {
        username: state.Account,
        password: state.Password,
      }
      const res = await UserService.resgister(loginParams)
      console.log(res)
    }
    return {
      ...toRefs(state),
      handleLogin,
      handleRegister 
    }
  },
})
</script>
查看原文

赞 53 收藏 32 评论 3

前端荣耀 赞了文章 · 4月6日

【译】让React组件如文档般展示的6大工具

原文 6 Tools for Documenting Your React Components Like a Pro

如果没有人能够理解并找到如何使用我们的组件,那它们有什么用呢?

React鼓励我们使用组件构建模块化程序。模块化给我们带来了非常多的好处,包括提高了可重用性。然而,如果你是为了贡献和复用组件,最好得让你的组件容易被找到、理解和使用。你需要将其文档化。

目前,使用工具可以帮助我们实现自动化文档工作流程,并使我们的组件文档变得丰富、可视化和可交互。有些工具甚至将这些文档组合为共享组件的工作流程的组成部分。

为了轻而易举地将我们的组件文档化,我收集了一些业界流行的工具,如果你有推荐的组件也可以评论留言。

1. Bit

共享组件的平台

clipboard.png

Bit不仅是一个将组件文档化的工具,它还是一个开源工具,支持你使用所有文件和依赖项封装组件,并在不同应用程序中开箱即用地运行它们。
Bit,你可以跨应用地共享和协作组件,你所有共享组件都可以被发现,以便你的团队在项目中查找和使用,并轻松共享他们自己的组件。

clipboard.png
在Bit中,你共享的组件可以在你们团队中的组件共享中心找到,你可以根据上下文、bundle体积、依赖项搜索组件,你可以非常快地找到已经渲染好的组件快照,并且选择使用它们。

浏览bit.dev上的组件

clipboard.png
当你进入组件详情页时,Bit提供了一个可交互的页面实时渲染展示组件,如果该组件包含js或md代码,我们可以对其进行代码修改和相关调试。

找到想要使用的组件后,只需使用NPM或Yarn进行安装即可。你甚至可以使用Bit直接开发和安装组件,这样你的团队就可以协作并一起构建。

clipboard.png

通过Bit共享组件,就不需要再使用存储库或工具,也不需要重构或更改代码,共享、文档化、可视化组件都集中在一起,并且也能实现开箱即用。

快速上手:
Share reusable code components as a team · Bit
teambit/bit

2. StoryBook & Styleguidist

StoryBook和StyleGuidist是非常棒的项目,可以帮助我们开发独立的组件,同时可以直观地呈现和文档化它们。

clipboard.png

StoryBook 提供了一套UI组件的开发环境。它允许你浏览组件库,查看每个组件的不同状态,以及交互式开发和测试组件。在构建库时,StoryBook提供了一种可视化和记录组件的简洁方法,不同的AddOns让你可以更轻松地集成到不同的工具和工作流中。你甚至可以在单元测试中重复使用示例来确认细微差别的功能。

clipboard.png

StyleGuidist是一个独立的React组件开发环境并且具备实时编译指引。它提供了一个热重载的服务器和即时编译指引,列出了组件propTypes并展示基于.md文件的可编辑使用示例。它支持ES6,Flow和TypeScript,并且可以直接使用Create React App。自动生成的使用文档可以帮助Styleguidist作为团队不同组件的文档门户。

类似的工具还有UiZoo

3. Codesandbox, Stackblitz & friends

组件在线编译器是一种非常巧妙的展示组件和理解他们如何运行的工具。当你可以将它们组合为文档的一部分(或作为共享组件的一部分)时,在线编译器可帮助你快速了解代码的工作方式并决定是否要使用该组件。

clipboard.png

Codesandbox是一个在线编辑器,用于快速创建和展示组件等小项目。创建一些有趣的东西后,你可以通过共享网址向他人展示它。CodeSandbox具有实时预览功能,可以在你输入代码时显示运行结果,并且可以集成到你的不同工具和开发工作流程中去。

clipboard.png

Stackblitz是一个由Visual Studio Code提供支持的“Web应用程序在线IDE”。与Codesnadbox非常相似,StackBlitz是一个在线IDE,你可以在其中创建通过URL共享的Angular和React项目。与Codesandbox一样,它会在你编辑时自动安装依赖项,编译,捆绑和热重载。

其他类似工具:
11 React UI Component Playgrounds for 2019

4. Docz

clipboard.png

Docz使你可以更轻松地为你的代码构建Gtabsy支持的文档网站。它基于MDX(Markdown + JSX),即利用markdown进行组件文档化。基本上,你可以在项目的任何位置编写.mdx文件,Docz会将其转换并部署到Netlify,简化你自己设计的文档门户的过程。非常有用不是吗?
pedronauck / docz

5. MDX-docs

clipboard.png

MDX-docs允许你使用MDX和Next.js记录和开发React组件。您可以将markdown与内联JSX混合以展示React组件。像往常一样写下markdown并使用ES导入语法在文档中使用自定义组件。内置组件会将JSX代码块渲染为具有可编辑代码并提供实时预览功能,由react-live提供支持。

jxnblk / MDX-文档

6. React Docgen

clipboard.png

React DocGen是一个用于从React组件文件中提取信息的CLI和工具箱,以便生成文档。它使用ast-types@babel/parser将源解析为AST,并提供处理此AST的方法。输出/返回值是JSON blob/JavaScript对象。它通过React.createClassES2015类定义或功能(无状态组件)为React组件提供了一个默认的定义。功能十分强大。

reactjs/react-docgen
callstack/component-docs

查看原文

赞 33 收藏 19 评论 2

前端荣耀 回答了问题 · 4月6日

sharethis 分享有时不显示?

没有使用过,无法帮助到,不好意思哈

关注 4 回答 3

前端荣耀 赞了文章 · 3月11日

M1芯片Mac搭建前端开发环境

M1芯片出来了有一段时间了,各类软件的兼容性已经跟进的差不多了,迫于 M1 实在太香,我的 M1 Macbook 也于本周到达了我的手上。在花费了两三个小时搭建完开发环境后,立马用它开始干活了。这几天的感受只有寥寥一语可以概括:太 TM 香了!太强了

作为一个前端开发者,我可以负责任的说前端开发的场景 M1 已经可以完全覆盖,并且体验非常好。目前我依赖的开发环境,并且已经跑在 M1 Mac 上的如下:适配 m1 的 Vscode Insiders 版本 + Node v15(on arm) + Node v14 (on intel) + Nvm + Cocoapods + Xcode + Flutter。可以看到,大前端这一套是完全能够覆盖的。

我在 github 的 issue 中经常看到有关于 m1 安装环境的问题,为了帮助不愿意翻 issue 的开发者,遂决定写下这篇文章记录前端环境的搭建问题。

我会从以下几个步骤来记录:

  • 安装 Xocde
  • 安装 Homebrew
  • 安装 Nvm
  • 安装 Node
  • 如何安装 v14 及以下的老版本 Node

安装 Xcode

Xcode 是苹果开发者工具,是一个功能非常强大的 IDE,可以用来开发苹果应用,并且自带了 git 环境, 并且有咱们后续依赖的 Xcode Command line tool , 先进入 app store 中安装 Xcode。安装好之后再进入后续步骤

安装 Homebrew

如果你是一个 mac 老用户,那自然不用我介绍什么是 Homebrew。若是你是一个新用户,那么请你一定要安装 Homebrew ,并使用它来管理 Mac 中的各类环境依赖。通过👇的命令即可安装 Homebrew

/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

在终端中执行如下命令安装 Homebrew,安装 Homebrew 的时候,常常会因为网络问题导致安装失败,这个问题只能依赖大家自行解决科学上网了。

安装 Nvm

Nvm 是一个管理 Node 版本的工具,当项目多了以后,总会碰到需要切换 Node 环境的时候,所以推荐大家使用 Nvm 来安装 Node。

Homebrew 安装 Nvm

若是你已经按上述步骤安装好了 Homebrew,那么你可以直接通过 Homebrew 的命令轻松安装 Nvm

brew install nvm

curl 安装 Nvm

若是你不想使用 Homebrew 管理依赖,那么用 👇 的 curl 直接安装 Nvm 也是可以的

curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.37.2/install.sh | bash

设置 Nvm 的环境变量

安装好 nvm 后,需要将环境变量写入我们的 shell 配置文件中。

如果你的默认 shell 的 zsh,那么将环境变量命令复制进 .zshrc:

vi ~/.bash_profile

如果不使用 zsh,那么将环境变量复制进 bash 中,通过如下命令创建 bash_profile,并且编辑 bash_profile。

touch ~/.bash_profile
vi ~/.bash_profile

最后将以下环境变量脚本 copy 进 shell 配置文件中:

export NVM_DIR="$([ -z "${XDG_CONFIG_HOME-}" ] && printf %s "${HOME}/.nvm" || printf %s "${XDG_CONFIG_HOME}/nvm")"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" # This loads nvm

至此 Nvm 已经安装好,可以尝试在命令行中输入 nvm,你可以看到 nvm 已经正常工作了。

安装 Node

接下来我们通过 nvm 来安装 Node,我们先安装适配了 M1 的 node 版本。

nvm i v15

该命令运行完后,会执行很久,编译 node ,大家耐心等就好了,大概需要 5-10 分钟,就会提示安装成功。再提醒一句,报任何错误,首先先检查是不是网络问题,例如 443 、 connect timeout 等,如果是网络问题,建议科学上网。

如何安装 v14 及以下的老版本 Node

安装 Node 的部分写的很简单,因为按这个步骤,一般不会出问题。而当你用 nvm 尝试去安装 v14 及以下的 Node 版本时,大概率会报错,而我们在工作中恰恰又可能依赖 v14 及以下的 lts 版本。那么为什么会报错呢?究其原因还是因为低版本的 node 并不是基于 arm64 架构的,所以不适配 M1 芯片。在这里教大家两个方法,就能成功安装上低版本 Node。

方法一

在终端中,输入:

arch -x86_64 zsh

通过这个命令可以让 shell 运行在Rosetta2下。
之后你可以通过 nvm install v12 来安装低版本 Node。
在此之后,您可以不用在 Rosetta2 中就可以使用安装的可执行文件,也就是说,您可以将 Node v15与其他节点版本互换使用。

方法二

方法二就是通过 Rosetta2 来启动终端,这样通过 Rosetta2 转译到 x86 架构中执行安装,也一样可以安装成功。

  • 在 finder 中,点击应用程序,并在实用工具中找到终端 (Terminal)
  • 右键终端,点击获取信息
  • 选择 使用Rosetta 打开
  • 重启终端,并执行 nvm install v12 命令

Rosetta

Rosetta2

结尾

至此,我们的 M1 就已经完成了 git + 各版本 Node + npm 的搭建,完成这部分工作后,前端项目已经可以正常运行,各位买了 M1 Mac 的前端小伙伴又能愉快的干活了。希望本文能够帮助被 M1 的开发环境困扰过的您。

查看原文

赞 15 收藏 9 评论 6

前端荣耀 赞了文章 · 3月11日

SPA 路由三部曲之实践演练

回顾

《SPA 路由三部曲之核心原理》的姊妹篇《SPA 路由三部曲之实践演练》终于要跟大家见面了,在开篇之前,我们先来回顾一下,在上一篇中都了解到了哪些知识:前端路由流行的模式有两种 hash 模式和 history 模式,两者分别利用浏览器自有特性实现单页面导航。

  • hash 模式:window.location 或 a 标签改变锚点值,window.hashchange() 监听锚点变化
  • history 模式:history.pushState()、history.repalceState() 定义目标路由,window.onpopstate() 监听浏览器操作导致的 URL 变化

那《SPA 路由三部曲之实践演练》会带来哪些内容呢,利用之前学到的核心原理,加上前端路由在 Vue 技术栈的使用方式,动手实现属于自己的前端路由管理器 myRouter。小编将使用原生 JS、Vue 分别实现 hash 模式路由与 history 模式路由。

大纲

原生 JS 实现路由

在基于技术栈 Vue 实现 myRouter 之前,先用原生 JS 小试牛刀。

实现 hash 路由

在使用原生 JS 实现 hash 路由,我们需要先明确,触发路由导航的方式都有哪些:

  • 利用 a 标签,触发锚点改变,监听 window.onhashchange() 进行路由导航
  • JS 动态改变 hash 值,window.location.hash 赋值,进行路由导航

那我们就一步步来,先来完成 HTML 的设计:

index.html

<body>
  <div>
      <h3>Hash 模式路由跳转</h3>
      <ul class="tab">
          <!-- 定义路由 -->
          <li><a href="#/home"> a 标签点击跳转 home</a></li>
          <li><a href="#/about"> a 标签点击跳转 about</a></li>
      </ul>
      <!-- JS 动态改变 hash 值,实现跳转 -->
      <div id="handleToCart"> JS 动态点击跳转 cart</div>  
      <!-- 渲染路由对应的 UI -->
      <div id="routeViewHash" class="routeView"></div> 
  </div>
</body>

接下来,将对 URL 的操作定义成 JSHashRouter 类

class JSHashRouter {
    constructor(routerview){
        this.routerView = routerview
    }
    init(){
        // 首次渲染如果不存在 hash 值,那么重定向到 #/,若存在 hash 值,就渲染对应的 UI
        if(!location.hash){
            location.hash="#/"
        }else{
            this.routerView.innerHTML = '当前路由:'+ location.hash
        }
        // 监听 hash 值改变
        window.addEventListener('hashchange', ()=>{
            this.routerView.innerHTML = '当前路由:'+ location.hash
        })
    }
    push(path){
        window.location.hash = path
    }
}

JSHashRouter 类自身定义了 routerView 属性,接收渲染路由 UI 的容器。JSHashRouter 类定义了 2 个方法:init() 和 push()

  • init()
    首先页面渲染时,不会触发 window.onhashchange(),根据当前 hash 值,渲染 routerView。监听 window.onhashchange() 事件,一旦事件触发,重新渲染 routerView。
  • push()
    在实际开发过程中,进行路由跳转需要使用 JS 动态设置。通过为 window.location.hash 设置值,实现路由跳转。这里需要注意,window.location.hash 改变 hash 值,也会触发 window.onhashchange() 事件。

所以不管是 a 标签改变 hash,还是 window.location.hash 改变 hash,统一在 window.onhashchange() 事件中,重新渲染 routerView。

接下来只要 JSHashRouter 实例化,调用就完成了 hash 路由:

index.html

<script type="text/javascript" data-original="./hash/hash.js"></script>
<script>
window.onload = function () {
    let routerview = document.getElementById('routeViewHash')
    // HashRouter 实例化
    let hashRouter = new JSHashRouter(routerview)  
    hashRouter.init()
    // 点击 handleToCart ,JS 动态改变 hash 值
    var handleToCart = document.getElementById('handleToCart');  
    handleToCart.addEventListener('click', function(){
        hashRouter.push('/cart')
    }, false); 
}
</script>

ok,来看一下效果:
hash-js

实现 history 路由

顺利实现了 hash 路由,按照相同的思路实现 History 路由前,明确触发 history 路由跳转的方式有哪些:

  • 动态触发 pushState()、replaceState()
  • 拦截 a 标签默认事件,检测 URL 变化,使用 pushState() 进行跳转。

History 模式触发路由跳转的方式与 Hash 模式稍有不同。通过《SPA 路由三部曲之核心原理》我们了解到,pushState()、replaceState()、a 标签改变 URL 都不会触发 window.onpopstate() 事件。那该怎么办,在实际开发过程中,我们是需要在 HTML 中进行跳转的。好在可以拦截 a 标签的点击事件,阻止 a 标签默认事件,检测 URL,使用 pushState() 进行跳转。

index.html

<div>
    <ul class="tab">
        <li><a href="/home">点击跳转 /home</a></li>
        <li><a href="/about">点击跳转 /about</a></li>
    </ul>
    <!-- JS 动态改变 URL 值,实现跳转 -->
    <div id="handlePush" class="btn"> JS 动态 pushState 跳转 list</div>
    <div id="handleReplace" class="btn"> JS 动态 replaceState 跳转 item</div>
    <!-- 渲染路由对应的 UI -->
    <div id="routeViewHistory" class="routeView"></div>
</div>

history 路由与 hash 路由定义的 html 模板大同小异,区别在于 a 标签 href 属性的定义方式不同,history 模式下的 URL 中是不存在 # 号的。接下来,到了定义 JSHistoryRouter 类的时候。

class JSHistoryRouter {
    constructor(routerview){
        this.routerView = routerview
    }
    init(){
        let that = this
        let linkList = document.querySelectorAll('a[href]')
        linkList.forEach(el => el.addEventListener('click', function (e) {
            e.preventDefault()  // 阻止 <a> 默认跳转事件
            history.pushState(null, '', el.getAttribute('href')) // 获取 URL,跳转
            that.routerView.innerHTML = '当前路由:' + location.pathname
        }))
        // 监听 URL 改变
        window.addEventListener('popstate', ()=>{
            this.routerView.innerHTML = '当前路由:' + location.pathname
        })
    }
    push(path){
        history.pushState(null, '', path)
        this.routerView.innerHTML = '当前路由:' + path
    }
    replace(path){
        history.replaceState(null, '', path)
        this.routerView.innerHTML = '当前路由:' + path
    }
}

与 JSHashRouter 类一样,JSHistoryRouter 类自身定义了 routerView 属性,接收渲染路由 UI 的容器。JSHistoryRouter 类同样定义了三个方法:init() 、push()、replace()。

  • init() 中主要做了两件事:

    1. 定义 window.onpopstate() 事件,用于监听 history.go()、history.back()、history.forword() 事件。
    2. 点击 a 标签,浏览器 URL 默认更新为 href 赋值的 url,使用 e.preventDefault() 阻止默认事件。将 href 属性赋值的 url 通过 pushState() 更新浏览器 URL,重新渲染 routerView。
  • push() 函数,通过 history.pushState() 新增浏览器 URL,重新渲染 routerView。
  • replace()函数,通过 history.replaceState() 替换浏览器 URL,重新渲染 routerView。

ok,现在只要实例化 JSHistoryRouter 类,调用方法就实现了 history 路由!
index.html

<script type="text/javascript" data-original="./history/history.js"></script>
<script>
window.onload = function () {
    let routerview = document.getElementById('routeViewHistory')
    let historyRouter = new JSHistoryRouter(routerview)  // HistoryRouter 实例化
    historyRouter.init()
    // JS 动态改变 URL 值
    document.getElementById('handlePush').addEventListener('click', function(){
        historyRouter.push('/list')
    }, false); 
    document.getElementById('handleReplace').addEventListener('click', function(){
        historyRouter.replace('/item')
    }, false); 
}
</script>

来来来,展示效果啦!

细心的同学应该发现了,在 HashRouter 类的 init() 方法中,处理了页面首次渲染的情况,但在 HistoryRouter 类中却没有。这是为什么呢?Hash 模式下,改变的是 URL 的 hash 值,浏览器请求是不携带 hash 值的,所以由 http://localhost:8080/#/home 到 http://localhost:8080/#/cart, 浏览器是不发送请求。 History 模式下,改变的则是 URL 除锚点外的其他部分,所以由 http://localhost:8080/cart 到 http://localhost:8080/home ,浏览器会重新发送请求。这也就解释了 vue-router 的 Hash 模式,后端不需要做任何处理,而 History 模式后端需要将域名下匹配不到的静态资源,重定向到同一个 index.html 页面。

好了,使用 JS 原生实现了基础的路由跳转,是不是更加期待基于 Vue 技术栈实现自己的路由了呢,马上安排!

源码直通车:https://github.com/yangxiaolu1993/jsForRouter

基于 Vue 实现路由

vue-router 与 react-router 是现在流行的单页面路由管理器。虽然二者的使用方式有些差别,但核心原理是大同小异的,只要掌握了其中一个,另一个也就不难理解了。我们参照 vue-router 的使用方式与功能,实现基于 Vue 技术栈的 myRouter。首先通过 vue-cli 搭建简单的 Vue 开发环境,搭建过程在这里就不赘述了,调整之后的目录如下:

├── config                   
├── index.html               
├── package.json
├── src
│   ├── App.vue
│   ├── main.js
│   ├── assets
│   │   ├── css
│   │   ├── image
│   │   └── js
│   ├── components
│   ├── plugins                  // 插件
│   │   └── myRouter             
│   │       ├── index.js         // MyRouter 类
│   │       └── install.js       // MyRouter 对象的 install 函数
│   ├── router
│   │   └── myRouter.js
│   ├── util
│   └── view
│       └── home.vue
├── static
└── vue.config.js

Vue Router 的本质

在实现 myRouter 之前,我们先来回忆一下 Vue 中是如何引入 Vue Router 的。

  1. 通过 NPM 安装 vue-router 依赖包

    import VueRouter from vue-router
  2. 定义路由变量 routes,每个路由映射一个组件

    const routes = [
        { path: '/foo', component: { template: '<div>foo</div>' } }, // 可以通过 import 等方式引入
        { path: '/bar', component: { template: '<div>bar</div>' } }
    ]
  3. router 实例化,将路由变量 routes 作为配置项

    const router = new VueRouter({
        mode:'history',
        routes // (缩写) 相当于 routes: routes
    })
  4. 通过 Vue.use(router) 使得 vue 项目中的每个组件都可以拥有 router 实例与当前路由信息
  5. 将 router 挂载在根实例上。

    export default new Vue({
        el: '#app',
        router
    })

通过这五步,就顺利完成了路由的配置工作,在任何组件内可以通过 this.&dollar;router 获得 router 实例,也可以通过 this.&dollar;route 获得当前路由信息。在配置路由的过程中,我们获得了哪些信息呢?

  • router 实例是通过 new VueRouter({...}) 创建的,也就是说,我们引入的 Vue Router 其实就是 VueRouter 类。
  • router 实例化的配置项是一个对象,也就是,VueRouter 类的 contractor 方法接收一个对象参数,routes、mode 是该对象的属性。
  • 通过 Vue.use(...) 为每个组件注入 router 实例,而 Vue.use() 其实是执行对象的 install 方法。

通过这三点信息,我们基本上可以将 MyRouter 的基本结构设计出来:

class MyRouter{
    constructor(options){
        this.routes = options.routes || []
        this.mode = options.mode || 'hash' // 模式 hash || history
        ......
    }
}
MyRouter.install = function(Vue,options){
    ......
}
export default MyRouter

将项目中 Vue Router 替换成定义好的 MyRouter ,运行项目,页面空白,没有报错。

按照自己的开发习惯,将 MyRouter 的基本结构拆分成两个 js 文件 install.js 与 index.js。install.js 文件用于实现 install 方法,index.js 用于实现 MyRouter 类。

Vue.use()

为了能更好的理解 Vue Router 的实现原理,简单了解一下 Vue.use() 在定义插件时,都可以做哪些事情。如果你已经掌握了 Vue.use() 的使用,可以直接跳过此小节。

Vue 官网描述:如果插件是一个对象,必须提供 install 方法。如果插件是一个函数,它会被作为 install 方法。install 方法调用时,会将 Vue、vue 实例化时传入的选项 options 作为参数传入,利用传入的 Vue 我们便可以定义一些全局的变量、方法、组件等。

  • Vue.component() 全局组件注册

    MyRouter.install = function(Vue,options){
        Vue.component("my-router-view", {template: '<div>渲染路由 UI</div>'})
    }

    项目中的任何组件都可以直接使用 <my-router-view>,不需要引入。

  • Vue.mixin() 全局注册一个混入

    MyRouter.install = function(Vue,options){
        Vue.mixin({
            beforeCreate(){
                Object.defineProperty(this,'$location',{
                    get(){ return  window.location}
                })
            }
        })
    }

    使用全局混入,它将影响每个之后单独创建的 vue 实例。通过 Object.defineProperty() 为注入的 vue 实例,添加新的属性 &dollar;location,运行项目,在每一个 vue 实例(组件)中,都可以通过 this.&dollar;location 获得 location 对象。

自定义插件中定义的全局组件、方法、过滤器、处理自定义选项注入逻辑等都是在 install 方法中完成的。

&dollar;myRouter 与 &dollar;myRoute

项目中使用了 vue-router 插件后,在每个 vue 实例中都会包含两个对象 &dollar;router 和 &dollar;route。

  • &dollar;router 是 Vue Router 的实例化对象,是全局对象,包含了所有 route 对象。
  • &dollar;route 是当前路由对象,每一个路由都是一个 route 对象,是局部对象。

&dollar;myRouter

在注册 vue-router 时,最后一步是将 router 挂载在根实例上,在实例化根组件的时候,将 router 作为其参数的一部分。也就是目前只有根组件有 router 实例,其他子组件都没有。问题来了,如何将 router 实例放到每个组件呢?

通过之前对 Vue.use() 的基本了解,我们知道:Vue.mixin() 可以全局注册一个混入,之后的每个组件都会执行。混入对象的选项将被“混合”进入该组件本身的选项,也就是每个组件在实例化时,install.js 中 Vue.mixin() 内定义的方法,会与组件中的方法合并。利用这个特性,就可以将 router 实例传递到每一个子组件。先来看一下代码:

install.js

MyRouter.install = function(Vue,options){
    Vue.mixin({
        beforeCreate(){
            if (this.$options && this.$options.myRouter){  // 根组件
                this._myRouter = this.$options.myRouter;
            }else { 
                this._myRouter= this.$parent && this.$parent._myRouter // 子组件
            }
            // 当前实例添加 $router 实例
            Object.defineProperty(this,'$myRouter',{
                get(){
                    return this._myRouter
                }
            })
        }
    })  
}

上述代码中,是在组件 beforeCreate() 阶段混入的路由信息,是有什么特殊的含义吗?在 Vue 初始化 beforeCreate 阶段,会将 Vue 之前定义的原型方法(Vue.prototype)、全局 API 属性、全局 Vue.mixin() 内的参数,合并成一个新的 options,并挂载到 &dollar;options 上。在根组件上,&dollar;options 可以获得到 myRouter 对象。this 指向的是当前 Vue 实例,即根实例,myRouter 对象通过自定义的 _myRouter 变量,挂载到根实例上。子组件在初始化 beforeCreate 阶段,除了合并 install 方法中定义的 Vue.mixin() 的内容外,还会将父组件的参数进行合并,比如父组件定义在子组件上的 props,当然还包括在根组件上自定义的 _myRouter 属性。

不知道大家有没有思考一个问题,当知道组件为子组件时,为什么就可以直接拿父组件的 _myRouter 对象呢?想要解释这个问题,我们先来思考一下,父组件与子组件生命周期的执行顺序:

父 beforeCreate -> 父 created -> 父 beforeMount -> 子 beforeCreate -> 子 create -> 子 beforeMount ->子 mounted -> 父 mounted

问题的答案是不是很清楚了,子组件 beforeCreate 执行时,父组件 beforeCreate 已经执行完了,_myRouter 已经挂载到父组件的实例上了。

最后,我们通过 Object.defineProperty 将 &dollar;myRouter 挂载到组件实例上。

&dollar;myRoute

&dollar;myRoute 用于存储当前路径。参照 vue-router 的 &dollar;route,&dollar;myRoute 的属性包括:path、name、hash、meta、fullPath、query、params,那如何获取这些值呢?不管在哪种路由模式下,MyRouter 类肯定会包含通过 URL 与配置的路由表做匹配,获得到目标路由信息的逻辑处理,&dollar;myRouter 又可以拿到 MyRouter 类的所有信息,是不是很完美。在 MyRouter 类中定义 current 属性,用于存储目标路由信息,current 会根据路由的改变而重新赋值,也就保证了 &dollar;myRouter 中一直目标路由的信息。

install.js

MyRouter.install = function(Vue,options){
    Vue.mixin({
        beforeCreate(){
            ......
            // 为当前实例添加 $route 属性
            Object.defineProperty(this,'$myRoute',{
                get(){
                    return this._myRouter.current
                }
            })
        }
    })  
}
current 属性的赋值过程会在 MyRouter 类实现时讲解。

MyRouterLink 组件与 MyRouterView 组件

好了,回到我们之前的话题,我们通过回顾 Vue Router 的引入,实现了 MyRouter 类的基本结构。接下来,我们在看看 Vue Router 是如何使用的。

Vue Router 定义了两个组件:<router-link>、<router-view>

  • <router-link> 用来路由导航,默认会被渲染成 a 标签,通过传入 to 属性定义目标路由
  • <router-view> 用来渲染匹配到的路由组件
<div id="app">
  <p>
    <router-link to="/foo">Go to Foo</router-link>
    <router-link to="/bar">Go to Bar</router-link>
  </p>
  <router-view></router-view>
</div>

仿照 Vue Router,在 myRouter 中同样添加两个全局组件 <my-router-view>、<my-router-link>。通过上面对 Vue.use() 定义插件的介绍,相信大家已经知道该如何在插件中定义组件了,那就先来定义 <my-router-link> 组件吧。

<router-link> 默认会被渲染成 a 标签,<router-link to="/foo">Go to Foo</router-link> 在最终在浏览器中渲染的结果是 <a href="/foo">Go to Foo</a>。当然,可以通过 <router-link> 的 tag prop 指定标签类型。小伙伴们有没有发现问题,按照常规的定义组件方式,在 <my-router-link> 组件中放的是 a 标签,<solt/> 代替模板内容:

<template>
  <a :href="to"><slot/></a>
</template>
<script>
export default {
  name: 'MyRouterLink',
  props:['to'],
  data () {return {}}
}
</script>

我们该如何定义 tag prop 呢?如果路由导航的触发条件不是 click,是 mouseover ,又如何定义呢?还记得 Vue 中提供的 render 渲染函数吗,render 函数结合数据生成 Virtual DOM 树、Diff 算法和 Patch 后生成新的组件。<my-router-link> 就使用 render 函数定义。

link.js

export default {
    name: 'MyRouterLink',
    functional: true,
    props: {
        to: {   // 目标导航
            type: [String, Object],
            required: true
        },
        tag:{   // 定义导航标签
            type: String,
            default: 'a'
        },
        event: {  // 触发事件
            type: String,
            default: 'click'
        }
    },
    render: (createElement, {props,parent}) => {
        let toRoute = parent.$myRoute.mode == 'hash'?`#/${props.to}`:`/${props.to}`
        return createElement(props.tag,{
            attrs: {
                href: toRoute
            },
            on:{
                [event]:function(){}
            }
        },context.children)
    }
};

ok,引用组件来看一下效果!

App.vue

<div class="footer">
    <my-router-link to="home" tag="p">首页</my-router-link>
    <my-router-link to='classify'>分类</my-router-link>
</div>


正常渲染,没有报错,使用 p 标签成功渲染,但问题也就接踵而至。<my-router-link> 默认渲染成 a 标签,history 路由模式下,a 标签默认的跳转事件,不仅会跳出当前的 document,还不能触发 popstate 事件。如果使用的是自定义标签,触发导航的时候,需要用 pushState 或 window.location.hash 更新 URL,难道这里还要写 if 判断?No,为何不阻止 a 标签的默认事件呢,这样一来,a 标签与自定义标签就没有差别了,导航标签都通过 pushState 或 window.location.hash 进行路由导航。

link.js

export default {
    ......
    render: (createElement, {props,parent,children}) => {    
        let toRoute = parent.$myRoute.mode == 'hash'?`#/${props.to}`:`/${props.to}`
        let on = {'click':guardEvent} 
        on[props.event] = e=>{
            guardEvent(e)  // 阻止导航标签的默认事件
            parent.$myRouter.push(props.to)   // props.to 的值传到 router.push()
        }
        return createElement(props.tag,{
            attrs: { href: toRoute },
            on,
        },children)
    }
};
function guardEvent(e){
    if (e.preventDefault) {
        e.preventDefault()
    }
}

太不容易了,单实现 MyRouterLink 组件就死了无数的脑细胞。接下来,让大脑放松一下!按照 MyRouterLink 组件的思路,实现 MyRouterView 组件。MyRouterView 的功能相对简单,将当前路由匹配到的组件进行渲染就行。还记得上面提到的 &dollar;myRoute 对象吗,将当前路由信息注入到了每个 vue 实例中,在 &dollar;myRoute 对象上新增 component 属性,存储路由对应的组件。其实,在 &dollar;route 的 matched 数组中,同样记录着组件信息,包括嵌套路由组件、动态路由匹配 regx 等等,小编只是将其简化。

view.js

export default {
    ......
    render: (createElement, {props, children, parent, data}) => {
        let temp = parent.$myRoute && parent.$myRoute.component?parent.$myRoute.component.default:''
        return createElement(temp)
    }
}

install.js

import MyRouterView from './view'
MyRouter.install = function(Vue,options){
    Vue.component(MyRouterView.name, MyRouterView)
}

又到了激动人心的时刻了,添加上 <my-router-view>,看看能不能如愿以偿!

App.vue

<template>
  <div id="app">
    <my-router-view/>
    <div class="footer">
        <my-router-link to="home" tag="p">首页</my-router-link>
        <my-router-link to='classify'>分类</my-router-link>
        <my-router-link to='cart'>购物车</my-router-link>
    </div>
  </div>
</template>

赞,MyRouter 插件已将初见雏形了,太不容易了!做好准备,关键内容来喽~~

核心类 MyRouter

终于到了完善 MyRouter 类了,在完善之前,我们先来确定一下,MyRouter 类需要做哪些事情。

  1. 接收 MyRouter 实例化时传入的选项 options,数组类型的路由表 routes、代表当前路由模式的 mode
  2. vue-router 使用时,不仅可以使用 path 匹配,还可以通过 name 匹配,路由表 routes 不方便路由匹配,将路由表转换成 key:value 的形式,key 为路由表 routes 配置的 path 或 name
  3. 为了区分路由的两种模式,创建 HashRouter 类、HistoryRouter 类,合称为 Router 类,新增 history 属性存储实例化的 Router 类
  4. 定义 current 属性,存储目标路由信息,通过 &dollar;myRoute 挂载到每一个实例上
  5. 定义 push()、replace()、go()方法

MyRouter 类做的事情还是挺多的,对于程序猿来说,只要逻辑清楚,再多都不怕。那就一点一点的来实现吧!先来将 MyRouter 类的基本结构搭起来。

index.js

class MyRouter {
    constructor(options){
        this.routes = options.routes || [] // 路由表
        this.mode = options.mode || 'hash' // 模式 hash || history
        this.routesMap = Utils.createMap(this.routes) // 路由表装换成 key:value 形式  
        this.history = null  // 存储实例化 HashRouter 或 HistoryRouter
        this.current= {
            name: '',
            meta: {},
            path: '/',
            hash: '',
            query:{},
            params: {},
            fullPath: '',
            component:null
        } // 记录当前路由
        // 根据路由模式,实例化 HashRouter 类、HistoryRouter 类
        switch (options.mode) {
            case 'hash':
                this.history = new HashRouter(this)
            case 'history':
                this.history = new HistoryRouter(this)
            default:
                this.history = new HashRouter(this)
        }
    }
    init(){
        this.history.init()
    }
    push(params){
        this.history.push(params)
    }
    replace(params){
        this.history.replace(params)
    }
    go(n){
        this.history.go(n)
    }
}
export default MyRouter
MyRouter 类初始化的时候,我们实例化的 Router 类放到了 history 属性中,在使用 MyRouter 类的 init()、 push()、replace()、go() 方法,就是在调用 HashRouter 类或 HistoryRouter 类的方法。所以在 HashRouter 类与 HistoryRouter 类中同样需要包含这 4 个方法。

Utils.createMap(routes)

createMap(routes){
    let nameMap = {} // 以每个路由的名称创建 key value 对象
    let pathMap = {} // 以每个路由的路径创建 key value 对象
    routes.forEach((route)=>{         
        let record = {
            path:route.path || '/',
            component:route.component,
            meta:route.meta || {},
            name:route.name || ''
        }
        if(route.name){
            nameMap[route.name] = record
        }
        if(route.path){
            pathMap[route.path] = record
        }
    })
    return {
        nameMap,
        pathMap
    }
}

将路由表 routes 中的每一个路由对象,重新组合,分别调整为以 route.path、route.name 为关键字,value 值为 record 对象的形式。这里只是一个简单的处理,并没有考虑嵌套路由、父组件路由、重定向、props 等情况。

我们在回过头来看看之前说的 MyRouter 类需要做的 5 件事情,现在还需要完成的就是定义 HashRouter 类与 HistoryRouter 类,并在其中为 current 赋值。废话不多说,开整!

HashRouter 类

顾名思义,HashRouter 类用来实现 Hash 模式下的路由的跳转。通过定义 MyRouter 类,可以明确 HashRouter 类需要定义 4 个函数。同时 HashRouter 类为 MyRouter 类中的 current 属性赋值,需要接收 MyRouter 类的参数。那么,HashRouter 类的基本框架就出来了。

hash.js

export default class HashRouter {
    constructor(router){
        this.router = router // 存储 MyRouter 对象
    }
    init(){}
    push(params){}
    replace(params){}
    go(n){}
}

实现 hash 模式路由跳转的关键就是监听 URL Hash 值的变化。还记得之前原生 JS 实现的 Hash 路由吗,原理是一模一样的,代码直接拿来用都可以。

export default class HashRouter {
    ......
    init(){
        this.createRoute()   // 页面首次加载时,判断当前路由 
        window.addEventListener('hashchange', this.handleHashChange.bind(this))  // 监听 hashchange
    }
    handleHashChange(){
        this.createRoute()
    }
    // 更新当前路由 current
    createRoute(){
        let path = location.hash.slice(1)  
        let route = this.router.routesMap.pathMap[path] 
        // 更新当前路由
        this.router.current = {
            name: route.name || '',
            meta: route.meta || {},
            path: route.path || '/',
            hash: route.hash || '',
            query:location.query || {},
            params: location.params || {},
            fullPath: location.href,
            component: route.component
        }  
    }
    ......
}

HashRouter 类与 JSHashRouter 类实现思路是一样的,稍有不同的是对 UI 渲染的方式。HashRouter 类是通过 <my-router-view> 渲染组件的。

HashRouter 类中的 createRoute() 获取 URL 的 hash 值,即 path 值,通过 MyRouter 类中的 routesMap.pathMap 对象,获取到当前路由匹配的路由配置选项,将路由配置选项合并到 myRouter.current 对象中,myRouter.current 通过 &dollar;myRoute 挂载到了每个实例中。也就是说,&dollar;myRoute.component 就是我们匹配到的路由组件,<my-router-view> 也是通过它确定要渲染的组件。

代码实现到这里,应该可以实现基本的跳转了,来看一下效果,验证一下!

页面初始渲染显示正常,点击底部导航也能正常跳转,但是为什么页面不更新呢?虽然我们通过 myRouter.current 将 hash 值变化与 <my-router-view> 建立了联系,但没有对 myRouter.current 进行双向绑定,说白了,<my-router-view> 并不知道 myRouter.current 的发生了改变。难道要实现双向绑定,当然不用!小编给大家安利一个 Vue 隐藏的 API:Vue.util.defineReactive(),了解过 Vue 源码的童鞋应该知道这个 API,用于定义一个对象的响应属性。那就简单了,在 MyRouter 插件初始化的时候,利用 Vue.util.defineReactive(),使 myRouter.current 得到监听。

install.js

MyRouter.install = function(Vue,options){
    Vue.mixin({
        beforeCreate(){
            ......
            Object.defineProperty(this,'$myRoute',{ .... })
            // 新增代码 利用 Vue defineReactive 监听当前路由的变化
            Vue.util.defineReactive(this._myRouter,'current')
        }
    })
}

现在来看看,<my-router-view> 的内容有没有更新。

太不容易了,终于得到我们想要的效果了。HashRouter 类的 push()、replace()、go() 方法,直接 copy JSHashRouter 类的代码就实现了。

export default class HashRouter {
    ......
    push(params){
        window.location.href = this.getUrl(params)
    }
    replace(params){
        window.location.replace(this.getUrl(params))
    }
    go(n){
        window.history.go(n)
    }
    // 获取当前 URL 
    getUrl(path){
        let path = ''
        if(Utils.getProperty(params) == 'string'){
            path = params
        } else if(params.name || params.path){
            path = params.name?params.name:params.path
        }
        const fullPath = window.location.href
        const pos = fullPath.indexOf('#')
        const p = pos > 0?fullPath.slice(0,pos):fullPath
        return `${p}#/${path}`
    }
}

参照 vue-router,动态导航方法可以是字符串,也可以是描述地址的对象,getUrl 函数处理 params 的各种情况。

HashRouter 类基本功能实现完了,是不是挺简单的,对 HistoryRouter 类的实现充满了信心,那就趁热打铁,实现 HistoryRouter 类走起!!

çççHistoryRouter 类**

HistoryRouter 类与 Hash 类一样,同样需要 push()、replace()、go() 动态设置导航,一个自有参数接收 MyRouter 类。唯一不同的就是处理监听 URL 变化的方式不一样。

history.js

export default class HistoryRouter {
    constructor(router){
        this.router = router
    }
    init(){
        window.addEventListener('popstate', ()=>{
            // 路由改变
        })
    }
    push(params){}
    replace(params){}
    go(n){}
}

history 路由模式导航是通过 history.pushState()、history.replaceState() 配合 window.onpopstate() 实现的。与 HashRouter 类一样,HistoryRouter 类的实现依然可以将原生 JS 实现的 JSHistoryRouter 类的代码直接拿过来。

history.js

export default class HistoryRouter {
    constructor(router){
        this.router = router
    }
    init(){
        // 监听 popstate
        window.addEventListener('popstate', ()=>{
            this.createRoute(this.getLocation())  // 导航 UI 渲染
        })
    }
    push(params){
        history.pushState(null, '', params.path)
        this.createRoute(params)  // 导航 UI 渲染
    }
    replace(params){
        history.replaceState(null, '', params.path)
        this.createRoute(params)  // 导航 UI 渲染
    }
    go(n){window.history.go(n)}
    getLocation () {
        let path = decodeURI(window.location.pathname)
        return (path || '/') + window.location.search + window.location.hash
    }
}

由于 history.pushState 与 history.replaceState 对浏览器历史状态进行修改并不会触发 popstate,只有浏览器的后退、前进键才会触发 popstate 事件。所以在通过 history.pushState、history.replaceState 对历史记录进行修改、监听 popstate 时,都要根据当前 URL 进行导航 UI 渲染。除了进行 UI 渲染,还有非常重要的一点,对 router.current 的更新,只要将其更新,<my-router-view> 才会更新。

history.js

export default class HistoryRouter {
    ......
    createRoute(params){
        let route = {}
        if(params.name){
            route = this.router.routesMap.nameMap[params.name]
        }else{
            let path = Utils.getProperty(params) == 'String'?params:params.path
            route = this.router.routesMap.pathMap[path]
        }
        // 更新路由
        this.router.current = {
            name: route.name || '',
            meta: route.meta || {},
            path: route.path || '/',
            hash: route.hash || '',
            query:location.query || {},
            params: location.params || {},
            fullPath: location.href,
            component: route.component
        }  
    }
}

细心的同学可能发现了问题,在原生 JS JSHistoryRouter 类中,将 a 标签的点击事件进行了改造,阻止了默认事件,使用 pushState 实现路由导航。HistoryRouter 类中为什么没有添加这个逻辑呢?还记得 <my-router-link> 的实现吗,其阻止了 a 标签的默认事件,统一使用 MyRouter 类的 push 方法,进行路由导航。

HistoryRouter 类的基本功能也实现完了,已经迫不及待想要验证代码的正确性了!

完美实现,有点小小的佩服自己!居然出现了可以参与编写 vue-router 的错觉。与 vue-router 相比,还有很多的功能没有实现,比如嵌套路由、路由导航守卫、过渡动画等等,myRouter 插件只是实现了简单的路由导航和页面渲染。往往一件事情最难的是第一步,myRouter 已经开了一个不错的头,相信完善之后的功能也不是问题。感兴趣的小伙伴可以跟小编一起继续开发下去!

源码直通车:https://github.com/yangxiaolu1993/my-router

总结

vue-router 实现思路不难,源码逻辑却是相当的复杂,MyRouter 相比 vue-router 虽然简化了很多,但整体思路是一致的。小编相信,MyRouter 的实现一定能帮助小伙伴更加快速的掌握 vue-router 的源码。小伙伴们在编写代码时,有没有跟小编一样的经历,需要不停地修改、完善之前的代码,哪怕当时想的很全面,也依然会亲手把代码删除,单单是 <my-router-link> 组件的实现,小编就修改了不下五遍。过程虽然痛苦,但结果却是收获却满满,成就感爆棚。由衷佩服 vue-router 的开发者,不知道他们是进行多少遍的修改,才能做到如今的地步!

欢迎大家期待之后《SPA 路由三部曲》的最后一篇,在这篇文章中,小编将带领大家进入 vue-router 的源码世界。相信在了解了 vue-router 的实现思路后,大家就都可以真正实现自己的前端路由了。

查看原文

赞 9 收藏 5 评论 0

认证与成就

  • 获得 389 次点赞
  • 获得 9 枚徽章 获得 0 枚金徽章, 获得 1 枚银徽章, 获得 8 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

注册于 2015-01-04
个人主页被 3.9k 人浏览