2024年12月

前言

没有看过
provide

inject
函数源码的小伙伴可能觉得他们实现数据多级传递非常神秘,其实他的源码非常简单,这篇文章欧阳来讲讲
provide

inject
函数是如何实现数据多级传递的。ps:本文中使用的Vue版本为
3.5.13

关注公众号:【前端欧阳】,给自己一个进阶vue的机会

看个demo

先来看个demo,这个是父组件,代码如下:

<template>
  <ChildDemo />
</template>

<script setup>
import ChildDemo from "./child.vue";
import { ref, provide } from "vue";
// 提供响应式的值
const count = ref(0);
provide("count", count);
</script>

在父组件中使用
provide
为后代组件注入一个
count
响应式变量。

再来看看子组件
child.vue
代码如下:

<template>
  <GrandChild />
</template>
<script setup>
import GrandChild from "./grand-child.vue";
</script>

从上面的代码可以看到在子组件中什么事情都没做,只渲染了孙子组件。

我们再来看看孙子组件
grand-child.vue
,代码如下:

<script setup>
import { inject } from "vue";

// 注入响应式的值
const count = inject("count");
console.log("inject count is:", count);
</script>

从上面的代码可以看到在孙子组件中使用
inject
函数拿到了父组件中注入的
count
响应式变量。

provide
函数

我们先来debug看看provide函数的代码,给父组件中的provide函数打个断点,如下图:
provide

刷新页面,此时代码将会停留在断点处。让断点走进provide函数,代码如下:

function provide(key, value) {
  if (!currentInstance) {
    if (!!(process.env.NODE_ENV !== "production")) {
      warn$1(`provide() can only be used inside setup().`);
    }
  } else {
    let provides = currentInstance.provides;
    const parentProvides = currentInstance.parent && currentInstance.parent.provides;
    if (parentProvides === provides) {
      provides = currentInstance.provides = Object.create(parentProvides);
    }
    provides[key] = value;
  }
}

首先判断
currentInstance
是否有值,如果没有就说明当前没有vue实例,也就是说当前调用provide函数的地方是不在setup函数中执行的,然后给出警告provide只能在setup中使用。

然后走进else逻辑中,首先从当前vue实例中取出存的
provides
属性对象。并且通过
currentInstance.parent.provides
拿到父组件vue实例中的
provides
属性对象。

这里为什么需要判断
if (parentProvides === provides)
呢?

因为在创建子组件时会默认使用父组件的
provides
属性对象作为父组件的
provides
属性对象。代码如下:

const instance: ComponentInternalInstance = {
  uid: uid++,
  vnode,
  type,
  parent,
  provides: parent ? parent.provides : Object.create(appContext.provides),
  // ...省略
}	

从上面的代码可以看到如果有父组件,那么创建子组件实例的时候就直接使用父组件的
provides
属性对象。

所以这里在provide函数中需要判断
if (parentProvides === provides)
,如果相等说明当前父组件和子组件是共用的同一个
provides
属性对象。此时如果子组件调用了provide函数,说明子组件需要创建自己的
provides
属性对象。

并且新的属性对象还需要能够访问到父组件中注入的内容,所以这里以父组件的
provides
属性对象为原型去创建一个新的子组件的,这样在子组件中不仅能够访问到原型链中注入的
provides
属性对象,也能够访问到自己注入进去的
provides
属性对象。

最后就是执行
provides[key] = value
将当前注入的内容存到
provides
属性对象中。

inject函数

我们再来看看inject函数是如何隔了一层子组件从父组件中如何取出数据的,还是一样的套路,给孙子组件中的inject函数打个断点。如下图:
inject

将断点走进inject函数,代码如下:

export function inject(
  key: InjectionKey<any> | string,
  defaultValue?: unknown,
  treatDefaultAsFactory = false,
) {
  // fallback to `currentRenderingInstance` so that this can be called in
  // a functional component
  const instance = currentInstance || currentRenderingInstance

  // also support looking up from app-level provides w/ `app.runWithContext()`
  if (instance || currentApp) {
    const provides = currentApp
      ? currentApp._context.provides
      : instance
        ? instance.parent == null
          ? instance.vnode.appContext && instance.vnode.appContext.provides
          : instance.parent.provides
        : undefined

    if (provides && key in provides) {
      return provides[key]
    } else if (arguments.length > 1) {
      return treatDefaultAsFactory && isFunction(defaultValue)
        ? defaultValue.call(instance && instance.proxy)
        : defaultValue
    } else if (__DEV__) {
      warn(`injection "${String(key)}" not found.`)
    }
  } else if (__DEV__) {
    warn(`inject() can only be used inside setup() or functional components.`)
  }
}

首先拿到当前渲染的vue实例赋值给本地变量
instance
。接着使用
if (instance || currentApp)
判断当前是否有vue实例,如果没有看看有没有使用
app.runWithContext
手动注入了上下文,如果注入了那么
currentApp
就有值。

接着就是一串三元表达式,如果使用
app.runWithContext
手动注入了上下文,那么就优先从注入的上下文中取出
provides
属性对象。

如果没有那么就看当前组件是否满足
instance.parent == null
,也就是说当前组件是否是根节点。如果是根节点就取app中注入的
provides
属性对象。

如果上面的都不满足就去取父组件中注入的
provides
属性对象,前面我们讲过了在inject函数阶段,如果子组件内没有使用inject函数,那么就会直接使用父组件的
provides
属性对象。如果子组件中使用了inject函数,那么就以父组件的
provides
属性对象为原型去创建一个新的子组件的
provides
属性对象,从而形成一条原型链。

所以这里的孙子节点的
provides
属性对象中当然就能够拿到父组件中注入的
count
响应式变量,那么
if (provides && key in provides)
就满足条件,最后会走到
return provides[key]
中将父组件中注入的响应式变量
count
原封不动的返回。

还有就是如果我们inject一个没有使用provide存入的key,并且传入了第二个参数
defaultValue
,此时
else if (arguments.length > 1)
就满足条件了。

在里面会去判断是否传入第三个参数
treatDefaultAsFactory
,如果这个参数的值为true,说明第二个参数
defaultValue
可能是一个工厂函数。那么就执行
defaultValue.call(instance && instance.proxy)

defaultValue
的当中工厂函数的执行结果进行返回。

如果第三个参数
treatDefaultAsFactory
的值不为true,那么就直接将第二个参数
defaultValue
当做默认值返回。

总结

这篇文章讲了使用
provide

inject
函数是如何实现数据多级传递的。

在创建vue组件实例时,子组件的
provides
属性对象会直接使用父组件的
provides
属性对象。如果在子组件中使用了
provide
函数,那么会以父组件的
provides
属性对象为原型创建一个新的
provides
属性对象,并且将
provide
函数中注入的内容塞到新的
provides
属性对象中,从而形成了原型链。

在孙子组件中,他的parent就是子组件。前面我们讲过了如果没有在组件内使用
provide
注入东西(很明显这里的子组件确实没有注入任何东西),那么就会直接使用他的父组件的
provides
属性对象,所以这里的子组件是直接使用的是父组件中的
provides
属性对象。所以在孙子组件中可以直接使用
inject
函数拿到父组件中注入的内容。

关注公众号:【前端欧阳】,给自己一个进阶vue的机会

另外欧阳写了一本开源电子书
vue3编译原理揭秘
,看完这本书可以让你对vue编译的认知有质的提升。这本书初、中级前端能看懂,完全免费,只求一个star。

开心一刻

今天和相亲对象见面,特意打扮了一番
见完面回到家后我给她发微信
我:我今天的形象怎么样
她:挺白净亮眼的
我:头发不油吧
她:反光,没看清
我:???

反光没看清

知识回顾

在我们的实际开发工程中,打包的 jar 通常会包含配置文件(例如:
application.yml
)来作为默认配置文件,然后在不同的环境用外部配置文件来覆盖 jar 包中配置文件配置的某些配置项,当然也可以全量覆盖;Spring Boot 关于外部配置(
Externalized Configuration
)有这么一段说明

External Application Properties

我给大家翻译一下

应用启动时,Spring Boot会自动从以下位置查找并加载
application.properties

application.yaml

  1. 从类路径

    a. 类路径根目录


    classpath根目录

    src/main/resources
    下的文件,默认情况下会被打包到类路径(classpath)下

    b. 类路径下的
    config

  2. 从当前目录

    a. 当前目录,也就 jar 所在目录

    b. 当前目录下的
    config
    目录

    c. 当前目录下的
    config
    目录的直接子目录

Spring Boot 会按如上顺序从上往下查找并加载
application.properties

application.yaml
,如果配置项重命名了,后加载的值会覆盖掉之前加载的值。配置文件中的配置项会以
PropertySources
实例的形式添加到 Spring 环境中

我们来看个示例:
spring-boot-external-config
,代码非常简单,我们只需要关注
ConfigDemo.java

/**
 * @author: 青石路
 */
@Component
public class ConfigDemo implements InitializingBean {

    private static final Logger LOGGER = LoggerFactory.getLogger(ConfigDemo.class);

    @Value("${retry.times}")
    private Integer retryTimes;
    @Value("${http.url}")
    private String httpUrl;

    @Override
    public void afterPropertiesSet() throws Exception {
        LOGGER.info("retryTimes:{}, httpUrl:{}", retryTimes, httpUrl);
    }
}

application.yml
内容如下

retry:
  times: 6
http:
  url: http://localhost:8080

我相信你们都能看懂,通过 Spring 注入进来两个配置项(
retry.times

http.url
)值,当应用中的全部属性都设置完成之后,Spring 会调用
afterPropertiesSet
方法,日志输出
retryTimes

httpUrl
的值。结合上述的加载顺序,我们来验证下是否如 Spring Boot 官方所说

  1. 从类路径

    这个我们只验证一种情况:类路径下只存在
    application.yml
    ;验证非常简单,直接在 jar 所在目录下


    类路径下配置文件生效

    执行

    java -jar spring-boot-external-config-1.0-SNAPSHOT.jar
    

    可以看到输出如下


    类路径下配置文件生效验证

    是不是没毛病?

  2. 从当前目录

    在 jar 所在目录下放一个
    application.yml


    当前目录下配置文件

    其内容如下

    retry:
      times: 2
    

    我特意拿掉了配置项
    http.url
    ,同样直接执行

    java -jar spring-boot-external-config-1.0-SNAPSHOT.jar
    

    输出结果如下


    当前目录下配置文件生效

    红框框住的值与蓝框框住的值,它们分别来自哪个配置文件,你们应该知道吧;也如 Spring Boot 官方所说,非常正常。我们再在当前目录下加个
    config
    目录


    当前目录下config配置文件

    其下放一个
    application.yml
    ,内容如下

    retry:
      times: 9
    http:
      url: http://127.0.0.1:8080
    

    执行结果如下


    当前目录下config配置文件验证

    也如 Spring Boot 官方描述的那样,没有任何毛病

    为了保持部署结构的简单清晰,我们往往会采用
    config
    目录的这种方式来放外部配置文件,包括应用配置文件、日志配置文件等等;但如果外部配置文件跟当前目录没有直接关系了,比如在其他盘或者其他目录下,那么如何指定外部配置文件呢?有但不限于如下两种(
    仔细斟酌如下写法是否正确


    1. java -jar spring-boot-external-config-1.0-SNAPSHOT.jar -Dspring.config.location=外部文件路径
    2. java -jar spring-boot-external-config-1.0-SNAPSHOT.jar --spring.config.location=外部文件路径

    外部文件按路径可以是绝对路径,也可以是相对路径,如果是相对路径,则以 jar 包所在的目录开始算

复现问题

一切准备就绪,我们往
Linux
服务器上部署,先将
spring-boot-external-config-1.0-SNAPSHOT.jar
上传到服务器,执行

java -jar spring-boot-external-config-1.0-SNAPSHOT.jar

日志输出如下

linux上类路径配置

这是 jar 中默认配置文件的配置值,没问题吧?我们再上传 jar 同级目录下的 config 文件夹,上传后目录结构如下

Linux上目录结构

执行后日志输出如下

linux上config配置

也和在 windows 上的演示结果一样,很正常。绝大部分情况下,我们的配置文件不止一个,所以从命名的准确性考虑,我们往往会将
config
目录命名成
configs

linux上configs配置

那么此时启动命令就需要调整下了

java -jar spring-boot-external-config-1.0-SNAPSHOT.jar -Dspring.config.location=configs/application.yml

java -jar spring-boot-external-config-1.0-SNAPSHOT.jar --spring.config.location=configs/application.yml

我们以
-D
的形式启动下,日志输出如下

linux上configs配置-D

这输出的还是 jar 包中的配置项值,并非 configs 目录下 application.yml 中的配置项值,读取外部配置文件失败了?带着疑问我们尝试下
--
方式

java -jar spring-boot-external-config-1.0-SNAPSHOT.jar --spring.config.location=configs/application.yml

发现能够正常加载

linux上configs配置--

为什么
-D
的方式会失败?

why

解决问题

出题出在哪,就出在 java 命令参数的顺序上,我们看下
java
命令的帮助文档

也就是说
-D
需要在
-jar
前面,所以正确读取 configs 目录下配置文件的命令应该是

java -Dspring.config.location=configs/application.yml -jar spring-boot-external-config-1.0-SNAPSHOT.jar

执行结果如下

正确加载外部目录的方式

总结

  1. 常用加载外部配置文件的命令参数


    java -Dspring.config.location=configs/application.yml -jar spring-boot-external-config-1.0-SNAPSHOT.jar

    java -jar spring-boot-external-config-1.0-SNAPSHOT.jar --spring.config.location=configs/application.yml


    -D
    紧随
    java
    之后,在
    -jar
    之前,是 JVM 参数;
    --
    在 jar 名称之后,是 Spring Boot 命令行参数。两种方式有其各自的顺序写法,切勿张冠李戴!

  2. 推荐做法

    在 jar 包所在目录创建
    config
    文件夹,将外部配置文件置于该 config 文件夹下,启动的时候可以减少启动参数,那么出错的概率就会降低

为什么需要CSS沙箱

在 qiankun 微前端框架中,由于每个子应用的开发和部署都是独立的,将主/子应用的资源整合到一起时,容易出现样式冲突的问题

因此,
需要 CSS 沙箱来解决样式冲突问题,实现主应用以及各子应用之间的样式隔离
,确保各自的样式独立运行,互不干扰

工程化手段

既然 CSS 沙箱是用来解决样式冲突的问题,那如果我通过工程化手段确保每个样式选择器名称都是唯一的,这样是不是就不需要 CSS 沙箱了?

使用工程化手段来生成唯一的 CSS 类名,常见解决方案有:

  1. BEM
    :不同项目用不同的前缀或命名规则来确保类名唯一性,避免样式冲突,详见
    BEM命名规范
  2. CSS Module
    :通过构建工具配置(详见
    webpack 启用 css-loader
    )在构建过程中自动生成唯一的类名。对了,vue3 中
    <style module>
    标签也会被编译为 CSS Module,详见
    Vue.js - 单文件组件|CSS功能
  3. CSS-in-JS
    : 在 JS 中定义 CSS 样式块,注入到 DOM 中,详见
    CSS-in-JS 指南

但是这些方案都存在一些问题:

  1. 历史包袱:对于老旧项目,尤其是那些未采用现代工程化手段的项目,修改现有代码以支持新的样式管理方案(如 BEM 或 CSS-in-JS)需要大量的重构工作
  2. 第三方库:即使你确保了自己的样式选择器唯一,第三方库的样式仍可能会导致冲突

显然,工程化手段只能解决一部分问题,在实际应用中,可能需要结合使用工程化手段和 CSS 沙箱,以应对不同的样式管理需求

乾坤沙箱

乾坤目前存在三种 CSS 隔离机制,分别是动态样式隔离、影子DOM沙箱和作用域沙箱

  1. 动态样式隔离
    :qiankun 默认开启,可以确保单实例场景子应用之间的样式隔离,但是无法确保主应用跟子应用、或者多实例场景的子应用样式隔离
  2. 影子DOM沙箱(Shadow DOM)
    :手动开启 ,qiankun 会为每个微应用的容器包裹上一个
    shadow dom
    节点,从而确保微应用的样式不会对全局造成影响
  3. 作用域沙箱(Scope CSS)
    :手动开启 ,qiankun 会改写子应用所添加的样式,为所有样式规则增加一个特殊的选择器规则来限定其影响范围

你可能想问,开关在呢❓如何手动开启我想要的沙箱机制❓❓❓

在这个
乾坤API - start({ })
中,有一个可选参数
sandbox
,用于控制是否开启沙箱以及开启哪种沙箱

  • true
    :默认值,开启动态样式隔离
  • { strictStyleIsolation: true }
    :开启影子DOM沙箱
  • { experimentalStyleIsolation: true }
    :开启作用域沙箱

动态样式隔离

乾坤会默认开启此沙箱

可以确保单实例场景子应用之间的样式隔离,但是无法确保主应用跟子应用、或者多实例场景子应用之间的样式隔离

实现原理是当子应用被加载时,其对应的样式会被注入到页面中;当子应用被卸载时,qiankun 会自动移除其样式,确保页面的样式环境保持干净

动态样式隔离虽然可以提供很好的隔离效果,但往往存在一些限制条件,所以在现实的使用中基本无法单独满足用户的需求

对于新的子应用,使用动态样式隔离 + 工程化手段两种方案结合的方式,基本能够解决样式冲突的问题

Shadow DOM 沙箱

手动开启,开启代码如下

import { registerMicroApps, start } from 'qiankun'

registerMicroApps([...]) // 注册子应用

start({ 
  sandbox: { strictStyleIsolation: true  }  // 开启 Shadow DOM 沙箱
}) 

这种模式下 qiankun 会为每个微应用的容器包裹上一个
shadow dom
节点,从而确保微应用的样式不会对全局造成影响

Shadow DOM是什么?

Shadow DOM 是 Web Components 技术的一部分,它允许开发者创建一个封闭的 DOM 树,这个 DOM 树的样式和脚本与页面的主 DOM 树是隔离的。通过 Shadow DOM,可以确保子应用的样式和脚本不会影响到主应用或其他子应用,从而避免冲突和干扰

Shadow DOM,可以理解为是存在于 DOM 中的 DOM

记住!影子DOM 是独立存在的 DOM,有自己的作用域集,外部的配置不会影响到内部,内部的配置也不会影响外部

影子 DOM 允许将隐藏的 DOM 树附加到常规 DOM 树中的元素上——这个影子 DOM 始于一个影子根,在其之下你可以用与普通 DOM 相同的方式附加任何元素

这里有一些影子 DOM 术语:

  • 影子宿主(Shadow host)
    : 影子 DOM 附加到的常规 DOM 节点
  • 影子树(Shadow tree)
    : 影子 DOM 内部的 DOM 树
  • 影子边界(Shadow boundary)
    : 影子 DOM 终止,常规 DOM 开始的地方
  • 影子根(Shadow root
    ): 影子树的根节点

说了这么多,那如何创建创建影子 DOM ?
我们可以调用宿主上的
attachShadow()
来创建影子 DOM

我们结合 乾坤小demo 实际演示一下,影子DOM到底有什么作用?

ok!我们创建了一个 qiankun 项目,现在主应用和子应用根节点类名相同,都是
.App
,主应用根节点背景色设置为黑色,子应用根节点背景色设置为红色

由于 qiankun 默认的动态样式隔离机制存在缺陷,无法确保主应用和子应用之间的样式隔离,我们发现,子应用污染了主应用的背景色样式

启用 Shadow DOM沙箱隔离机制,Later~,一切正常

实现原理

这里我们实现一下 Shadow DOM 沙箱机制的核心逻辑,对应乾坤的源代码在
createElement
方法,可以看这里 -
Shadow DOM沙箱源代码

其原理也很简单,就是将子应用模板包裹在 Shadow DOM 中,使其形成一个独立的样式作用域,确保其样式隔离

<body>
  <div id="root">qiankun 是一个基于 single-spa 的微前端实现库</div>
  <script>
    // 子应用的模版字符串
    const template = `<div id="qiankun-xxx">
                        <div id="app">Shadow DOM 沙箱</div>    
                        <style>div{color:red}</style>
                    </div>`

    function createElement(appContent) {
      const containerElement = document.createElement('div')
      containerElement.innerHTML = appContent
      const appElement = containerElement.firstChild // 影子宿主(template模版字符串转换成了真实的dom)

      const shadow = appElement.attachShadow({ // 影子DOM(调用宿主上的 attachShadow() 来创建影子 DOM)
        mode: 'open',
      })
      shadow.innerHTML = appElement.innerHTML // 给Shadow DOM附加宿主节点下的内容

      appElement.innerHTML = ''
      return appElement
    }

    document.body.appendChild(createElement(template))
  </script>
</body>

虽然 Shadow DOM 是一个强大的技术 ,但在某些场景下,它并不是一个完美的解决方案

比如,
越界的 DOM 操作
,在实际应用中,子应用可能会有操作主文档 DOM 的需求,比如动态地向主文档
document
添加全局组件、弹窗等。这些操作会创建 Shadow DOM 之外的元素,Shadow DOM 的内部样式也就无法对这些元素生效

基于 ShadowDOM 的严格样式隔离并不是一个可以无脑使用的方案,大部分情况下都需要接入应用做一些适配后才能正常在 ShadowDOM 中运行起来(比如 react 场景下需要解决这些
问题
,使用者需要清楚开启了
strictStyleIsolation
意味着什么 - 摘抄自 qiankun 文档

Scope CSS (Scoped CSS)

手动开启,开启代码如下

import { registerMicroApps, start } from 'qiankun'

registerMicroApps([...]) // 注册子应用

start({ 
  sandbox: { experimentalStyleIsolation: true  }  // 开启作用域沙箱
}) 

这是 qiankun 一个实验性的样式隔离特性,
它的核心思想是通过给子应用中的所有样式选择器添加一个唯一的前缀选择
div[data-qiankun="xxx"]
,来限制这些样式的作用范围

对于一个选择器,如果需要限制它的作用范围,可以使用组合选择器的方式。在当前选择器A前面加一个选择器B,使得选择器A只作用在选择器B内部的节点

改写后的代码会表达为如下结构

// 假设 registerMicroApps 方法注册的子应用 name 是 react16
.app-main {
  font-size: 14px;
}

// 改写后
div[data-qiankun="react16"] .app-main {
  font-size: 14px;
}

实现原理

提取和解析样式
:当一个子应用被加载时,qiankun 会提取子应用中的所有
<style>
标签内嵌样式和
<link>
标签引入的外部样式,并对其进行解析,获取所有的 CSS 规则

重写样式规则
:qiankun 给每个子应用的包裹容器新增唯一标识符
data-qiankun
属性,值为通过
registerMicroApps
API 注册子应用的
name
;然后修改子应用的样式选择器,添加前缀选择器
div[data-qiankun="xxx"]
,重写选择器

由于作用域沙箱不能直接修改
link
标签引入的外部样式,所以会把
link
外部样式转化为
style
内嵌样式,再给其添加前缀

对应乾坤源代码的入口是
createElement
方法,可以看这里 -
Scope CSS沙箱源代码

function createElement(
appContent: string,
strictStyleIsolation: boolean,
scopedCSS: boolean,
appName: string,
): HTMLElement {
  const containerElement = document.createElement('div');
  containerElement.innerHTML = appContent;
  const appElement = containerElement.firstChild as HTMLElement;
  
  /**
   * CSS样式冲突的处理方式
   * 1. shadowDOM
   * 2. scoped CSS
   */
  if (strictStyleIsolation) {
    // ... shadowDOM 沙箱逻辑
  }
  
  if (scopedCSS) {
    // 常量 css.QiankunCSSRewriteAttr = 'data-qiankun'
    const attr = appElement.getAttribute(css.QiankunCSSRewriteAttr);
    if (!attr) {
      // 给子应用的包裹容器新增 data-qiankun 属性,值为通过 registerMicroApps 注册子应用的 name
      appElement.setAttribute(css.QiankunCSSRewriteAttr, appName);
    }

    // 遍历子应用的所有样式,修改其样式选择器,添加前缀选择器 div[data-qiankun="xxx"]
    const styleNodes = appElement.querySelectorAll('style') || [];
    forEach(styleNodes, (stylesheetElement: HTMLStyleElement) => {
      css.process(appElement!, stylesheetElement, appName);
    });
  }
  
  return appElement;
}

不足的话,应该是解析子应用的 style 样式,并为每个选择器添加前缀。这一过程在子应用的加载和渲染时会增加额外的计算开销,尤其是在样式表很大或者包含大量选择器的情况下,可能会影响页面的初始加载性能

沙箱方案

实际的工作中,选择合适的沙箱方案需要根据具体的场景和需求来决定。 以下是一些常见的场景及其对应的沙箱选择

单实例模式

单实例模式指的是一次仅加载一个子应用的场景,这种模式下子应用之间不会并发运行,避免了同时多个应用运行导致的冲突

在这种模式下,
动态样式隔离+ 工程化手段(如 BEM 命名规范、CSS Modules)
通常就能满足大部分需求。因为在单实例模式中,不需要担心子应用之间的样式和脚本冲突问题。

多实例模式

在多实例模式下,多个子应用可能同时加载和运行,子应用之间的样式和脚本容易产生冲突

在这种模式下,需要更强的隔离性。可以使用
作用域沙箱(Scoped CSS Sandbox)+ Shadow DOM 沙箱
的组合

参考文档

GitHub - careyke/frontend_knowledge_structure: qiankun中CSS沙箱的实现

究竟什么是Shadow DOM?

使用影子 DOM - Web API | MDN

前言

本文介绍HTTP请求授权工作原理、配置及适用场景,配合以下内容观看效果更佳!!!

一、HTTP请求授权工作原理

​ 基于Spring Security最新的Http请求授权讲解,不再使用旧版的请求授权

  1. 授权过滤器AuthorizationFilter获取认证信息
  2. 调用RequestMatcherDelegatingAuthorizationManager的check方法验证该用户是否具有该请求的授权
  3. RequestMatcherDelegatingAuthorizationManager根据配置的请求和授权关系校验用户是否具有当前请求的授权并返回授权结果
  4. AuthorizationFilter处理授权结果,授权成功则继续调用过滤器链,否则抛出AccessDeniedException异常
  5. 认证失败时,ExceptionTranslationFilter处理AccessDeniedException异常,如果是当前认证是匿名认证或者RememberMe认证则调用AuthenticationEntryPoint的commence方法,否则调用AccessDeniedHandler的handler方法
  6. 工作原理图如下

authorizationfilter

二、HTTP请求授权配置

1、添加用户权限

package com.yu.demo.spring.impl;

import com.yu.demo.entity.UserDetailsImpl;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.List;
import java.util.UUID;

@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    //@Autowired
    //private UserService userService;
    // @Autowired
    //private UserRoleService userRoleService;


    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        //TODO 通过username从数据库中获取用户,将用户转UserDetails
        //User user = userService.getByUsername(username);
        //TODO 从数据库实现查询权限并转化为List<GrantedAuthority>
        //List<String> roleIds = userRoleService.listRoleIdByUsername(username);
        //List<GrantedAuthority> grantedAuthorities = new ArrayList<>(roleIds.size());
        //roleIds.forEach(roleId -> grantedAuthorities.add(new SimpleGrantedAuthority(roleId)));
        //return new User(username, user.getPassword(), user.getEnable(), user.getAccountNonExpired(), user.getCredentialsNonExpired(), user.getAccountNonLocked(), user.getAuthorities());
        //测试使用,指定权限
        List<GrantedAuthority> grantedAuthorities = new ArrayList<>();
        //与hasXxxRole匹配时添加ROLE_前缀
        grantedAuthorities.add(new SimpleGrantedAuthority("ROLE_ADMIN"));
        //与hasXxxAuthority匹配时原始值
        grantedAuthorities.add(new SimpleGrantedAuthority("OPERATE"));
        //{noop}不使用密码加密器,密码123的都可以验证成功
        UserDetailsImpl userDetails = new UserDetailsImpl(username, "{noop}123", true, true, true, true, grantedAuthorities);
        //userDetails中设置token,该token只是实现认证流程,未使用jwt
        userDetails.setToken(UUID.randomUUID().toString());
        return userDetails;
    }

}

2、配置ExceptionTranslationFilter自定义异常处理器

  • 因AuthorizationFilter授权失败时会抛出异常,该异常由ExceptionTranslationFilter处理,所以要配置自定义的异常处理器。

  • 自定义AccessDeniedHandler和AuthenticationEntryPoint异常处理器(可以用一个类实现认证授权相关的所有接口,也可以使用多个类分别实现)。

package com.yu.demo.spring.impl;


import com.yu.demo.entity.ApiResp;
import com.yu.demo.entity.UserDetailsImpl;
import com.yu.demo.util.SpringUtil;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
import org.springframework.stereotype.Component;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.HashSet;
import java.util.Set;

@Component
public class LoginResultHandler implements AuthenticationSuccessHandler, LogoutSuccessHandler, AuthenticationEntryPoint, AuthenticationFailureHandler, AccessDeniedHandler {

    /**
     * 登录成功
     */
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
        UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = (UsernamePasswordAuthenticationToken) authentication;
        UserDetailsImpl userDetailsImpl = (UserDetailsImpl) usernamePasswordAuthenticationToken.getPrincipal();
        //token返回到前端
        SpringUtil.respJson(response, ApiResp.success(userDetailsImpl.getToken()));
    }

    /**
     * 登录失败
     */
    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        SpringUtil.respJson(response, ApiResp.loginFailure());
    }

    /**
     * 登出成功
     */
    @Override
    public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        SpringUtil.respJson(response, ApiResp.success());
    }

    /**
     * 未登录调用需要登录的接口时
     */
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        SpringUtil.respJson(response, ApiResp.notLogin());
    }

    /**
     * 已登录调用未授权的接口时
     */
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        SpringUtil.respJson(response, ApiResp.forbidden());
    }
}

  • 配置异常处理:
                //异常处理配置
                .exceptionHandling(exceptionHandlingCustomizer -> exceptionHandlingCustomizer
                        //授权失败处理器(登录账号访问未授权的资源时)
                        .accessDeniedHandler(loginResultHandler)
                        //登录失败处理器(匿账号访问需要未授权的资源时)
                        .authenticationEntryPoint(loginResultHandler))

3、HTTP请求授权配置

  • 本文使用最新的authorizeHttpRequests(AuthorizationFilter+AuthorizationManager)配置,不在使用authorizeRequests(FilterSecurityInterceptor+AccessDecisionManager+AccessDecisionVoter)
  • 请求和授权配置成对出现,配置在前的优先级更高
  • 请求种类
    • antMatchers:Ant风格的路径模式,
      ?
      (匹配一个字符)、
      *
      (匹配零个或多个字符,但不包括目录分隔符)、
      **
      (匹配零个或多个目录)
    • mvcMatchers:Spring MVC的路径模式,支持路径变量和请求参数
    • regexMatchers:正则表达式路径模式
    • requestMatchers:实现RequestMatcher自定义匹配逻辑
    • anyRequest:未匹配的其他请求,只能有一个且只能放在最后
  • 授权种类
    • permitAll:匿名或登录用户都允许访问
    • denyAll:匿名和登录用户都不允许访问
    • hasAuthority:有配置的权限允许访问,AuthorityAuthorizationManager校验
    • hasRole:有配置的角色允许访问,ROLE_{配置角色}与用户权限匹配,AuthorityAuthorizationManager校验
    • hasAnyAuthority:有配置的任意一个权限的允许访问,AuthorityAuthorizationManager校验
    • hasAnyRole:有配置的任意一个角色允许访问,ROLE_{配置角色}与用户权限匹配,AuthorityAuthorizationManager校验
    • authenticated:已认证(不包括匿名)的允许访问,AuthenticatedAuthorizationManager校验
    • access:自定义授权处理
  • 因authorizeHttpRequests不支持使用anonymous()的方式配置匿名访问,未自定义匿名角色时可以通过hasRole("ANONYMOUS")或者hasAuthority("ROLE_ANONYMOUS")或其他类似的方式实现允许匿名请求的设置

  • http请求授权配置
                //http请求授权
                .authorizeHttpRequests(authorizeHttpRequestsCustomizer -> authorizeHttpRequestsCustomizer
                        //不允许访问
                        .antMatchers("/test/deny")
                        .denyAll()
                        //允许匿名访问
                        .antMatchers("/test/anonymous")
                        .hasRole("ANONYMOUS")
                        //允许访问
                        .antMatchers("/test/permit")
                        .permitAll()
                        //测试使用:拥有ADMIN角色
                        .antMatchers("/test/admin")
                        //拥有ROLE_ADMIN权限,配置的角色不能以ROLE_作为前缀
                        .hasRole("ADMIN")
                        //测试使用:拥有OPERATE权限
                        .antMatchers("/test/operate")
                        //拥有OPERATE权限
                        .hasAuthority("OPERATE")
                        //其他的任何请求
                        .anyRequest()
                        //需要认证,且不能是匿名
                        .authenticated())
  • 完整过滤器链配置
package com.yu.demo.config;

import com.yu.demo.spring.filter.RestfulLoginConfigurer;
import com.yu.demo.spring.filter.RestfulUsernamePasswordAuthenticationFilter;
import com.yu.demo.spring.impl.LoginResultHandler;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer;
import org.springframework.security.config.annotation.web.configurers.FormLoginConfigurer;
import org.springframework.security.config.annotation.web.configurers.HttpBasicConfigurer;
import org.springframework.security.config.annotation.web.configurers.SessionManagementConfigurer;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.context.SecurityContextRepository;

@Configuration
@EnableWebSecurity
public class SecurityConfig {
    //登录参数用户名
    private static final String LOGIN_ARG_USERNAME = "username";
    //登录参数密码
    private static final String LOGIN_ARG_PASSWORD = "password";
    //登录请求类型
    private static final String LOGIN_HTTP_METHOD = HttpMethod.POST.name();
    //登录请求地址
    private static final String LOGIN_URL = "/login";
    //登出请求地址
    private static final String LOGOUT_URL = "/logout";

    @Autowired
    private LoginResultHandler loginResultHandler;
    @Autowired
    private SecurityContextRepository securityContextRepository;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
        httpSecurity
                //禁用UsernamePasswordAuthenticationFilter、DefaultLoginPageGeneratingFilter、DefaultLogoutPageGeneratingFilter
                .formLogin(FormLoginConfigurer::disable)
                //禁用BasicAuthenticationFilter
                .httpBasic(HttpBasicConfigurer::disable)
                //禁用CsrfFilter
                .csrf(CsrfConfigurer::disable)
                //禁用SessionManagementFilter
                .sessionManagement(SessionManagementConfigurer::disable)
                //异常处理配置
                .exceptionHandling(exceptionHandlingCustomizer -> exceptionHandlingCustomizer
                        //授权失败处理器(登录账号访问未授权的资源时)
                        .accessDeniedHandler(loginResultHandler)
                        //登录失败处理器(匿账号访问需要未授权的资源时)
                        .authenticationEntryPoint(loginResultHandler))
                //http请求授权
                .authorizeHttpRequests(authorizeHttpRequestsCustomizer -> authorizeHttpRequestsCustomizer
                        //不允许访问
                        .antMatchers("/test/deny")
                        .denyAll()
                        //允许匿名访问
                        .antMatchers("/test/anonymous")
                        .hasRole("ANONYMOUS")
                        //允许访问
                        .antMatchers("/test/permit")
                        .permitAll()
                        //测试使用:拥有ADMIN角色
                        .antMatchers("/test/admin")
                        //拥有ROLE_ADMIN权限,配置的角色不能以ROLE_作为前缀
                        .hasRole("ADMIN")
                        //测试使用:拥有OPERATE权限
                        .antMatchers("/test/operate")
                        //拥有OPERATE权限
                        .hasAuthority("OPERATE")
                        //其他的任何请求
                        .anyRequest()
                        //需要认证,且不能是匿名
                        .authenticated())
                //安全上下文配置
                .securityContext(securityContextCustomizer -> securityContextCustomizer
                        //设置自定义securityContext仓库
                        .securityContextRepository(securityContextRepository)
                        //显示保存SecurityContext,官方推荐
                        .requireExplicitSave(true))
                //登出配置
                .logout(logoutCustomizer -> logoutCustomizer
                        //登出地址
                        .logoutUrl(LOGOUT_URL)
                        //登出成功处理器
                        .logoutSuccessHandler(loginResultHandler)
                )
                //注册自定义登录过滤器的配置器:自动注册自定义登录过滤器;
                //需要重写FilterOrderRegistration的构造方法FilterOrderRegistration(){},在构造方法中添加自定义过滤器的序号,否则注册不成功
                .apply(new RestfulLoginConfigurer<>(new RestfulUsernamePasswordAuthenticationFilter(LOGIN_ARG_USERNAME, LOGIN_ARG_PASSWORD, LOGIN_URL, LOGIN_HTTP_METHOD), LOGIN_URL, LOGIN_HTTP_METHOD))
                //设置登录地址:未设置时系统默认生成登录页面,登录地址/login
                .loginPage(LOGIN_URL)
                //设置登录成功之后的处理器
                .successHandler(loginResultHandler)
                //设置登录失败之后的处理器
                .failureHandler(loginResultHandler);

        //创建过滤器链对象
        return httpSecurity.build();
    }

}

三、测试接口

1、测试类

package com.yu.demo.web;

import com.yu.demo.entity.ApiResp;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/test")
public class TestController {

    @GetMapping("/hello")
    public ApiResp hello() {
        return ApiResp.success("hello");
    }

    /**
     * 匿名允许访问接口地址
     */
    @GetMapping("/anonymous")
    public ApiResp anonymous() {
        return ApiResp.success("anonymous");
    }

    /**
     * 禁止访问接口地址
     */
    @GetMapping("/deny")
    public ApiResp deny() {
        return ApiResp.success("deny");
    }

    /**
     * 允许访问接口地址
     */
    @GetMapping("/permit")
    public ApiResp permit() {
        return ApiResp.success("permit");
    }

    /**
     * 拥有ADMIN角色或ROLE_ADMIN权限允许访问接口地址
     */
    @GetMapping("/admin")
    public ApiResp admin() {
        return ApiResp.success("admin");
    }

    /**
     * 拥有OPERATE权限的允许访问接口地址
     */
    @GetMapping("/operate")
    public ApiResp operate() {
        return ApiResp.success("operate");
    }

}

2、测试

  1. 登录获取token

  1. admin接口测试

  1. 其他接口不在一一测试,有疑问或问题评论或私聊

四、总结

  1. 授权是拿用户的权限和可以访问接口的权限进行匹配,匹配成功时授权成功,匹配失败时授权失败
  2. 用户的权限对象是SimpleGrantedAuthority,字符串属性role
  3. 接口的role权限会通过ROLE_{role}转化为SimpleGrantedAuthority及其字符串属性role
  4. 接口的authority权限会直接转化为SimpleGrantedAuthority及其字符串属性role
  5. 拥有ROLE_ANONYMOUS权限或者ANONYMOUS角色可以访问匿名接口
  6. 后续会讲使用HTTP请求授权+自定义AuthorizationManager方式实现基于RBAC权限模型,欢迎持续关注
  7. 源码下载

大家好,我是汤师爷~

新零售业务涉及多个销售渠道,每个渠道都有其独特的业务特点,需要相应的营销方式、运营策略和供应链管理。

主要销售渠道包括:实体门店(包括直营连锁店、加盟门店)、电商平台销售(如淘宝、天猫、京东、拼多多等)、新兴流量平台(如抖音、小红书、快手等短视频平台)、本地生活平台(如美团、饿了么)。

尽管新零售的销售渠道多样化,但其核心理念始终是以消费者为中心,致力于提供便利、优质且无缝的购物体验。

全渠道交易模式

线上与线下的无缝连接是全渠道交易的核心。消费者既可以在线上下单后,到门店取货或选择配送到家,也可以直接到店体验和消费,享受灵活的购物体验。

这种模式充分发挥了各渠道的独特优势。线上渠道提供便捷的购物体验,线下渠道则增强消费者的信任感,两者相辅相成,满足了消费者的多样化需求。

当消费者在销售渠道完成交易后,商品可能分布在不同的仓储点或门店中。订单履约系统会根据预约时间和收货地址等信息,进行智能路由,优化配送路径,以最快速度、最低成本完成配送,从而提升消费体验。

全渠道交易模式通过将多渠道与不同服务能力进行深度融合,既提升了企业的运营效率,又为消费者带来了更加丰富的购物体验。

线上线下交易流程

线上交易流程涵盖了两个核心业务场景:一是电商购物流程,让消费者能够随时随地在线选购商品,并通过快递完成物流配送;二是O2O购物流程,将线上订购与线下门店服务结合。

线下交易流程则主要以门店收银流程为核心,这是实体门店运营中最基础且最关键的交易环节,涉及商品扫码、价格计算、支付处理等一系列操作。

电商购物流程

电商购物流程是一个完整的线上交易过程,包括消费者进店浏览、将商品加入购物车、确认订单并结算、提交订单、等待商家发货、物流配送,最后确认收货完成交易。整个过程全部在线上完成,为消费者提供便捷的购物体验。电商购物流程主要流程环节包括:

  • 消费者进店:用户通过各种入口(如小程序、分享链接、广告等)访问电商店铺,浏览商品列表和详情页面,了解商品信息和价格。
  • 加入购物车:用户在浏览过程中,可以将心仪的商品添加到购物车中暂存,方便后续统一结算,也可以随时调整商品数量或删除不需要的商品。
  • 确认订单并结算:用户在购物车中选择要购买的商品,确认收货地址、选择配送方式、使用优惠券或积分等,系统会自动计算订单总金额。
  • 提交订单:确认订单信息无误后,用户选择支付方式(如支付宝、微信支付、银行卡等)完成支付,系统生成正式订单。
  • 等待发货:仓库收到订单后进行订单处理,包括拣货、商品打包、出库等准备工作。
  • 物流配送:仓库将商品交付给物流公司,物流公司按照收货地址进行配送,用户可以实时查看物流信息和包裹位置。
  • 确认收货:用户收到商品后,检查商品完好无损,确认商品符合预期后,在系统中确认收货,完成交易。

O2O购物流程

O2O(Online to Offline)购物流程是一种将线上订购与线下服务相结合的购物模式。消费者通过手机应用或小程序在线下单,然后可以选择到实体店自提或由商家提供即时配送服务。这种模式特别适用于餐饮、生鲜等即时性需求强、配送半径有限的本地化商品和服务。

为什么门店需要拓展线上渠道?

传统的实体门店服务范围有限,只能吸引周边500米以内的消费者。因此,如何拓展服务范围,吸引更多的消费者到店,成为了店家迫切需要解决的问题。

缺乏忠实顾客,客户基础不稳,往往是一次性购物,门店无法形成有效的顾客回流。在当前的市场环境下,构建并维护粉丝群体,成为了商家的核心竞争力。

运营成本不断增长,包括租金和人工成本的上涨,但是广告投放、宣传又成本高昂,且难以追踪效果,达不到预期目标。如何有效吸引新客和提升销售业绩,变得至关重要。

电商不断挤压生存空间,随着网购成为人们的一种生活习惯,由于其方便和价格优势,再加上退换货几乎不产生成本,电商对于实体门店构成了巨大的竞争压力。

O2O购物流程主要流程环节包括:

  • 消费者基于LBS进店:用户通过手机应用或小程序,根据当前位置搜索附近的门店,查看门店商品、营业时间和评价等信息,选择合适的门店进行购物。
  • 加入购物车:浏览门店商品列表,将需要的商品添加到购物车中,可以随时调整商品数量或删除不需要的商品。
  • 选择履约方式并结算:确认购物车商品后,用户可以选择到店自提或即时配送服务,系统会根据所选方式计算相应的配送费用,同时可以使用优惠券或会员折扣。
  • 提交订单:确认订单信息和配送方式后,用户选择支付方式完成支付,系统生成正式订单。
  • 派单到门店:系统将订单信息推送给相应的门店,门店收到订单通知并确认接单。
  • 门店备货:门店员工根据订单信息准备商品,确保商品质量和数量符合要求。
  • 自提或骑手配送:如果是自提订单,用户到店出示取货码领取商品;如果是配送订单,门店将商品交给配送骑手进行配送。
  • 确认收货:用户收到商品后,检查商品状态,确认无误后在系统中完成收货,交易完成。

电商与O2O流程差异

电商购物流程主要服务于远程配送的商品交易,而O2O购物流程则针对需要到店自提或即时配送的场景。

我们以瑞幸咖啡为例,下图为瑞幸小程序首页,有到店取、幸运送、电商购的购物入口,其中到店取、幸运送为O2O购物模式,电商购为电商购物模式。

两种业务模式的主要差异如下:

对比维度 电商购物模式 O2O购物模式
消费场所 完全在线上进行,从进店、选择商品、下单、支付到收货,消费者在线上即可完成购物全过程。 结合了线上和线下的消费场景。消费者可能在线上选购商品或服务,但可能在实体店进行自提或体验服务。
服务范围 通常覆盖全国地区,不太受地理位置的限制。 服务范围受限于实体店的位置,更侧重于本地化服务。
物流配送 赖于第三方物流或自建物流进行商品配送,消费者通常在家中等待收取快递。 消费者可以到店自提商品,或者通过骑手配送商品。
售后服务 售后服务主要通过线上进行沟通和处理,包括退货、换货、维修等。 后服务可以在线上进行,也可以提供线下服务点,让消费者有更多选择。

线下购物流程

线下收银流程是实体门店中完成商品交易的标准操作流程。它包括顾客选购商品、收银员扫码或手动录入商品信息、计算金额、选择支付方式(如现金、银行卡或移动支付)、完成支付交易、打印小票等环节。线下收银流程主要流程环节包括:

  • 消费者选品:客户在店内挑选商品并带到收银台。
  • 录入商品:当客户选好商品后,店员会在POS系统里输入商品信息,通常是扫描条码或手动输入商品代码。
  • 选择支付方式:店员询问客户希望使用的支付方式。POS系统支持多种支付方式,包括现金、信用卡、借记卡、移动支付(如微信支付、支付宝)等。
  • 消费者支付:根据客户所选的支付方式进行支付。银行卡使用POS机刷卡支付;移动支付时,读取用户手机付款码,进行支付;对于现金交易,店员收钱并找零。
  • 打印小票:交易完成后,POS系统会自动打印购物小票。小票详细列出了购买的商品、数量、价格、支付方式以及交易时间等信息,可能包含保修信息或退换货政策。
  • 售后:包括退换货处理、顾客咨询和解决可能出现的任何问题。顾客如果对购买的商品不满意,可以凭借小票到店内进行退换。

本文已收录于,我的技术网站:
tangshiye.cn
里面有,算法Leetcode详解,面试八股文、BAT面试真题、简历模版、架构设计,等经验分享。