手把手教你写一个简易的微前端框架

import(‘http://localhost:8001/js/app.js’)

},

mount() {

// …

},

unmount() {

// …

},

})

},

activeRule: (location) => location.hash === ‘#/vue’

})

这种方式也不靠谱,每次子应用的入口资源文件变了,主应用的代码也得跟着变。还好,我们有第三种方式,那就是在注册子应用的时候,把子应用的入口 URL 写上,由微前端来负责加载资源文件。

registerApplication({

// 子应用入口 URL

pageEntry: ‘http://localhost:8081’

// …

})

“自动”加载资源文件

现在我们来看一下如何自动加载子应用的入口文件(只在第一次加载子应用时执行):

export default function parseHTMLandLoadSources(app: Application) {

return new Promise(async (resolve, reject) => {

const pageEntry = app.pageEntry

// load html

const html = await loadSourceText(pageEntry)

const domparser = new DOMParser()

const doc = domparser.parseFromString(html, ‘text/html’)

const { scripts, styles } = extractScriptsAndStyles(doc as unknown as Element, app)

// 提取了 script style 后剩下的 body 部分的 html 内容

app.pageBody = doc.body.innerHTML

let isStylesDone = false, isScriptsDone = false

// 加载 style script 的内容

Promise.all(loadStyles(styles))

.then(data => {

isStylesDone = true

// 将 style 样式添加到 document.head 标签

addStyles(data as string[])

if (isScriptsDone && isStylesDone) resolve()

})

.catch(err => reject(err))

Promise.all(loadScripts(scripts))

.then(data => {

isScriptsDone = true

// 执行 script 内容

executeScripts(data as string[])

if (isScriptsDone && isStylesDone) resolve()

})

.catch(err => reject(err))

})

}

上面代码的逻辑:

1.利用 ajax 请求子应用入口 URL 的内容,得到子应用的 HTML2.提取 HTML 中 script style 的内容或 URL,如果是 URL,则再次使用 ajax 拉取内容。最后得到入口页面所有的 script style 的内容3.将所有 style 添加到 document.head 下,script 代码直接执行4.将剩下的 body 部分的 HTML 内容赋值给子应用要挂载的 DOM 下。

下面再详细描述一下这四步是怎么做的。

一、拉取 HTML 内容

export function loadSourceText(url: string) {

return new Promise((resolve, reject) => {

const xhr = new XMLHttpRequest()

xhr.onload = (res: any) => {

resolve(res.target.response)

}

xhr.onerror = reject

xhr.onabort = reject

xhr.open(‘get’, url)

xhr.send()

})

}

代码逻辑很简单,使用 ajax 发起一个请求,得到 HTML 内容。e807c2aa034fba2d7d22641215a10f57.png上图就是一个 vue 子应用的 HTML 内容,箭头所指的是要提取的资源,方框标记的内容要赋值给子应用所挂载的 DOM。

二、解析 HTML 并提取 style script 标签内容

这需要使用一个 API DOMParser[13],它可以直接解析一个 HTML 字符串,并且不需要挂到 document 对象上。

const domparser = new DOMParser()

const doc = domparser.parseFromString(html, ‘text/html’)

提取标签的函数 extractScriptsAndStyles(node: Element, app: Application) 代码比较多,这里就不贴代码了。这个函数主要的功能就是递归遍历上面生成的 DOM 树,提取里面所有的 style script 标签。

三、添加 style 标签,执行 script 脚本内容

这一步比较简单,将所有提取的 style 标签添加到 document.head 下:

export function addStyles(styles: string[] | HTMLStyleElement[]) {

styles.forEach(item => {

if (typeof item === ‘string’) {

const node = createElement(‘style’, {

type: ‘text/css’,

textContent: item,

})

head.appendChild(node)

} else {

head.appendChild(item)

}

})

}

js 脚本代码则直接包在一个匿名函数内执行:

export function executeScripts(scripts: string[]) {

try {

scripts.forEach(code => {

new Function(‘window’, code).call(window, window)

})

} catch (error) {

throw error

}

}

四、将剩下的 body 部分的 HTML 内容赋值给子应用要挂载的 DOM 下

为了保证子应用正常执行,需要将这部分的内容保存起来。然后每次在子应用 mount() 前,赋值到所挂载的 DOM 下。

// 保存 HTML 代码

app.pageBody = doc.body.innerHTML

// 加载子应用前赋值给挂载的 DOM

app.container.innerHTML = app.pageBody

app.mount()

现在我们已经可以非常方便的加载子应用了,但是子应用还有一些东西需要修改一下。

子应用需要做的事情

在 V1 版本里,注册子应用的时候有一个 loadApp() 方法。微前端框架在第一次加载子应用时会执行这个方法,从而拿到子应用暴露的三个方法。现在实现了 pageEntry 功能,我们就不用把这个方法写在主应用里了,因为不再需要在主应用里引入子应用。

但是又得让微前端框架拿到子应用暴露出来的方法,所以我们可以换一种方式暴露子应用的方法:

// 每个子应用都需要这样暴露三个 API,该属性格式为 mini-single-spa-${appName}

window[‘mini-single-spa-vue’] = {

bootstrap,

mount,

unmount

}

这样微前端也能拿到每个子应用暴露的方法,从而实现加载、卸载子应用的功能。

另外,子应用还得做两件事:

1.配置 cors,防止出现跨域问题(由于主应用和子应用的域名不同,会出现跨域问题)2.配置资源发布路径

如果子应用是基于 webpack 进行开发的,可以这样配置:

module.exports = {

devServer: {

port: 8001, // 子应用访问端口

headers: {

‘Access-Control-Allow-Origin’: ‘*’

}

},

publicPath: “//localhost:8001/”,

}

一个完整的示例

示例代码在 examples 目录。

registerApplication({

name: ‘vue’,

pageEntry: ‘http://localhost:8001’,

activeRule: pathPrefix(‘/vue’),

container: $(‘#subapp-viewport’)

})

registerApplication({

name: ‘react’,

pageEntry: ‘http://localhost:8002’,

activeRule:pathPrefix(‘/react’),

container: $(‘#subapp-viewport’)

})

start()

d2d26facfe155ce7b567b8dc99bb28f1.gif

V3 版本


V3 版本主要添加以下两个功能:

1.隔离子应用 window 作用域2.隔离子应用元素作用域

隔离子应用 window 作用域

在 V2 版本下,主应用及所有的子应用都共用一个 window 对象,这就导致了互相覆盖数据的问题:

// 先加载 a 子应用

window.name = ‘a’

// 后加载 b 子应用

window.name = ‘b’

// 这时再切换回 a 子应用,读取 window.name 得到的值却是 b

console.log(window.name) // b

为了避免这种情况发生,我们可以使用 Proxy[14] 来代理对子应用 window 对象的访问:

app.window = new Proxy({}, {

get(target, key) {

if (Reflect.has(target, key)) {

return Reflect.get(target, key)

}

const result = originalWindow[key]

// window 原生方法的 this 指向必须绑在 window 上运行,否则会报错 “TypeError: Illegal invocation”

// e.g: const obj = {}; obj.alert = alert; obj.alert();

return (isFunction(result) && needToBindOriginalWindow(result)) ? result.bind(window) : result

},

set: (target, key, value) => {

this.injectKeySet.add(key)

return Reflect.set(target, key, value)

}

})

从上述代码可以看出,用 Proxy 对一个空对象做了代理,然后把这个代理对象作为子应用的 window 对象:

1.当子应用里的代码访问 window.xxx 属性时,就会被这个代理对象拦截。它会先看看子应用的代理 window 对象有没有这个属性,如果找不到,就会从父应用里找,也就是在真正的 window 对象里找。2.当子应用里的代码修改 window 属性时,会直接在子应用的代理 window 对象上修改。

那么问题来了,怎么让子应用里的代码读取/修改 window 时候,让它们访问的是子应用的代理 window 对象?

刚才 V2 版本介绍过,微前端框架会代替子应用拉取 js 资源,然后直接执行。我们可以在执行代码的时候使用 with[15] 语句将代码包一下,让子应用的 window 指向代理对象:

export function executeScripts(scripts: string[], app: Application) {

try {

scripts.forEach(code => {

// ts 使用 with 会报错,所以需要这样包一下

// 将子应用的 js 代码全局 window 环境指向代理环境 proxyWindow

const warpCode = `

;(function(proxyWindow){

with (proxyWindow) {

(function(window){${code}\n}).call(proxyWindow, proxyWindow)

}

})(this);

`

new Function(warpCode).call(app.sandbox.proxyWindow)

})

} catch (error) {

throw error

}

}

卸载时清除子应用 window 作用域

当子应用卸载时,需要对它的 window 代理对象进行清除。否则下一次子应用重新加载时,它的 window 代理对象会存有上一次加载的数据。刚才创建 Proxy 的代码中有一行代码 this.injectKeySet.add(key),这个 injectKeySet 是一个 Set 对象,存着每一个 window 代理对象的新增属性。所以在卸载时只需要遍历这个 Set,将 window 代理对象上对应的 key 删除即可:

for (const key of injectKeySet) {

Reflect.deleteProperty(microAppWindow, key as (string | symbol))

}

记录绑定的全局事件、定时器,卸载时清除

通常情况下,一个子应用除了会修改 window 上的属性,还会在 window 上绑定一些全局事件。所以我们要把这些事件记录起来,在卸载子应用时清除这些事件。同理,各种定时器也一样,卸载时需要清除未执行的定时器。

下面的代码是记录事件、定时器的部分关键代码:

// 部分关键代码

microAppWindow.setTimeout = function setTimeout(callback: Function, timeout?: number | undefined, …args: any[]): number {

const timer = originalWindow.setTimeout(callback, timeout, …args)

timeoutSet.add(timer)

return timer

}

microAppWindow.clearTimeout = function clearTimeout(timer?: number): void {

if (timer === undefined) return

originalWindow.clearTimeout(timer)

timeoutSet.delete(timer)

}

microAppWindow.addEventListener = function addEventListener(

type: string,

listener: EventListenerOrEventListenerObject,

options?: boolean | AddEventListenerOptions | undefined,

) {

if (!windowEventMap.get(type)) {

windowEventMap.set(type, [])

}

windowEventMap.get(type)?.push({ listener, options })

return originalWindowAddEventListener.call(originalWindow, type, listener, options)

}

microAppWindow.removeEventListener = function removeEventListener(

type: string,

listener: EventListenerOrEventListenerObject,

options?: boolean | AddEventListenerOptions | undefined,

) {

const arr = windowEventMap.get(type) || []

for (let i = 0, len = arr.length; i < len; i++) {

if (arr[i].listener === listener) {

arr.splice(i, 1)

break

}

}

return originalWindowRemoveEventListener.call(originalWindow, type, listener, options)

}

下面这段是清除事件、定时器的关键代码:

for (const timer of timeoutSet) {

originalWindow.clearTimeout(timer)

}

for (const [type, arr] of windowEventMap) {

for (const item of arr) {

originalWindowRemoveEventListener.call(originalWindow, type as string, item.listener, item.options)

}

}

缓存子应用快照

之前提到过子应用每次加载的时候会都执行 mount() 方法,由于每个 js 文件只会执行一次,所以在执行 mount() 方法之前的代码在下一次重新加载时不会再次执行。

举个例子:

window.name = ‘test’

function bootstrap() { // … }

function mount() { // … }

function unmount() { // … }

上面是子应用入口文件的代码,在第一次执行 js 代码时,子应用可以读取 window.name 这个属性的值。但是子应用卸载时会把 name 这个属性清除掉。所以子应用下一次加载的时候,就读取不到这个属性了。

为了解决这个问题,我们可以在子应用初始化时(拉取了所有入口 js 文件并执行后)将当前的子应用 window 代理对象的属性、事件缓存起来,生成快照。下一次子应用重新加载时,将快照恢复回子应用上。

生成快照的部分代码:

const { windowSnapshot, microAppWindow } = this

const recordAttrs = windowSnapshot.get(‘attrs’)!

const recordWindowEvents = windowSnapshot.get(‘windowEvents’)!

// 缓存 window 属性

this.injectKeySet.forEach(key => {

recordAttrs.set(key, deepCopy(microAppWindow[key]))

})

// 缓存 window 事件

this.windowEventMap.forEach((arr, type) => {

recordWindowEvents.set(type, deepCopy(arr))

})

恢复快照的部分代码:

const {

windowSnapshot,

injectKeySet,

microAppWindow,

windowEventMap,

onWindowEventMap,

} = this

const recordAttrs = windowSnapshot.get(‘attrs’)!

const recordWindowEvents = windowSnapshot.get(‘windowEvents’)!

recordAttrs.forEach((value, key) => {

injectKeySet.add(key)

microAppWindow[key] = deepCopy(value)

})

recordWindowEvents.forEach((arr, type) => {

windowEventMap.set(type, deepCopy(arr))

for (const item of arr) {

originalWindowAddEventListener.call(originalWindow, type as string, item.listener, item.options)

}

})

隔离子应用元素作用域

我们在使用 document.querySelector() 或者其他查询 DOM 的 API 时,都会在整个页面的 document 对象上查询。如果在子应用上也这样查询,很有可能会查询到子应用范围外的 DOM 元素。为了解决这个问题,我们需要重写一下查询类的 DOM API:

// 将所有查询 dom 的范围限制在子应用挂载的 dom 容器上

Document.prototype.querySelector = function querySelector(this: Document, selector: string) {

const app = getCurrentApp()

if (!app || !selector || isUniqueElement(selector)) {

return originalQuerySelector.call(this, selector)

}

// 将查询范围限定在子应用挂载容器的 DOM 下

return app.container.querySelector(selector)

}

Document.prototype.getElementById = function getElementById(id: string) {

// …

}

将查询范围限定在子应用挂载容器的 DOM 下。另外,子应用卸载时也需要恢复重写的 API:

Document.prototype.querySelector = originalQuerySelector

Document.prototype.querySelectorAll = originalQuerySelectorAll

// …

除了查询 DOM 要限制子应用的范围,样式也要限制范围。假设在 vue 应用上有这样一个样式:

body {

color: red;

}

当它作为一个子应用被加载时,这个样式需要被修改为:

/* body 被替换为子应用挂载 DOM 的 id 选择符 */

#app {

color: red;

}

实现代码也比较简单,需要遍历每一条 css 规则,然后替换里面的 bodyhtml 字符串:

const re = /^(\s|,)?(body|html)\b/g

// 将 body html 标签替换为子应用挂载容器的 id

cssText.replace(re, #${app.container.id})

V4 版本


V3 版本实现了 window 作用域隔离、元素隔离,在 V4 版本上我们将实现子应用样式隔离。

第一版

我们都知道创建 DOM 元素时使用的是 document.createElement() API,所以我们可以在创建 DOM 元素时,把当前子应用的名称当成属性写到 DOM 上:

Document.prototype.createElement = function createElement(

tagName: string,

options?: ElementCreationOptions,

): HTMLElement {

const appName = getCurrentAppName()

const element = originalCreateElement.call(this, tagName, options)

appName && element.setAttribute(‘single-spa-name’, appName)

return element

}

这样所有的 style 标签在创建时都会有当前子应用的名称属性。我们可以在子应用卸载时将当前子应用所有的 style 标签进行移除,再次挂载时将这些标签重新添加到 document.head 下。这样就实现了不同子应用之间的样式隔离。

移除子应用所有 style 标签的代码:

export function removeStyles(name: string) {

const styles = document.querySelectorAll(style[single-spa-name=${name}])

styles.forEach(style => {

removeNode(style)

})

return styles as unknown as HTMLStyleElement[]

}

第一版的样式作用域隔离完成后,它只能对每次只加载一个子应用的场景有效。例如先加载 a 子应用,卸载后再加载 b 子应用这种场景。在卸载 a 子应用时会把它的样式也卸载。如果同时加载多个子应用,第一版的样式隔离就不起作用了。

第二版

由于每个子应用下的 DOM 元素都有以自己名称作为值的 single-spa-name 属性(如果不知道这个名称是哪来的,请往上翻一下第一版的描述)。

a496407ff903bedf2b90cf2c7e5db92d.png所以我们可以给子应用的每个样式加上子应用名称,也就是将这样的样式:

div {

color: red;

}

改成:

div[single-spa-name=vue] {

color: red;

}

这样一来,就把样式作用域范围限制在对应的子应用所挂载的 DOM 下。

给样式添加作用域范围

现在我们来看看具体要怎么添加作用域:

/**

  • 给每一条 css 选择符添加对应的子应用作用域

    1. a {} -> a[single-spa-name=${app.name}] {}
    1. a b c {} -> a[single-spa-name=${app.name}] b c {}
    1. a, b {} -> a[single-spa-name= a p p . n a m e ] , b [ s i n g l e − s p a − n a m e = {app.name}], b[single-spa-name= app.name],b[singlespaname={app.name}] {}
    1. body {} -> # 子应用挂载容器的 i d [ s i n g l e − s p a − n a m e = {子应用挂载容器的 id}[single-spa-name= 子应用挂载容器的id[singlespaname={app.name}] {}
    1. @media @supports 特殊处理,其他规则直接返回 cssText

*/

主要有以上五种情况。

通常情况下,每一条 css 选择符都是一个 css 规则,这可以通过 style.sheet.cssRules 获取:

ab90712d962c4cbfb2ac512786cb13c2.png拿到了每一条 css 规则之后,我们就可以对它们进行重写,然后再把它们重写挂载到 document.head 下:

function handleCSSRules(cssRules: CSSRuleList, app: Application) {

let result = ‘’

Array.from(cssRules).forEach(cssRule => {

const cssText = cssRule.cssText

const selectorText = (cssRule as CSSStyleRule).selectorText

result += cssRule.cssText.replace(

selectorText,

getNewSelectorText(selectorText, app),

)

})

return result

}

let count = 0

const re = /^(\s|,)?(body|html)\b/g

function getNewSelectorText(selectorText: string, app: Application) {

const arr = selectorText.split(‘,’).map(text => {

const items = text.trim().split(’ ')

items[0] = ${items[0]}[single-spa-name=${app.name}]

return items.join(’ ')

})

// 如果子应用挂载的容器没有 id,则随机生成一个 id

let id = app.container.id

if (!id) {

id = ‘single-spa-id-’ + count++

app.container.id = id

}

// 将 body html 标签替换为子应用挂载容器的 id

return arr.join(‘,’).replace(re, #${id})

}

核心代码在 getNewSelectorText() 上,这个函数给每一个 css 规则都加上了 [single-spa-name=${app.name}]。这样就把样式作用域限制在了对应的子应用内了。

效果演示

大家可以对比一下下面的两张图,这个示例同时加载了 vue、react 两个子应用。第一张图里的 vue 子应用部分字体被 react 子应用的样式影响了。第二张图是添加了样式作用域隔离的效果图,可以看到 vue 子应用的样式是正常的,没有被影响。

34b78d1017bdd40f0e936f1c5e526be7.png

faa63a6d24fac96082d8435f928666ef.png

V5 版本


V5 版本主要添加了一个全局数据通信的功能,设计思路如下:

1.所有应用共享一个全局对象 window.spaGlobalState,所有应用都可以对这个全局对象进行监听,每当有应用对它进行修改时,会触发 change 事件。2.可以使用这个全局对象进行事件订阅/发布,各应用之间可以自由的收发事件。

下面是实现了第一点要求的部分关键代码:

export default class GlobalState extends EventBus {

private state: AnyObject = {}

private stateChangeCallbacksMap: Map<string, Array> = new Map()

set(key: string, value: any) {

this.state[key] = value

this.emitChange(‘set’, key)

}

get(key: string) {

return this.state[key]

}

onChange(callback: Callback) {

const appName = getCurrentAppName()

if (!appName) return

const { stateChangeCallbacksMap } = this

if (!stateChangeCallbacksMap.get(appName)) {

stateChangeCallbacksMap.set(appName, [])

}

stateChangeCallbacksMap.get(appName)?.push(callback)

}

emitChange(operator: string, key?: string) {

this.stateChangeCallbacksMap.forEach((callbacks, appName) => {

/**

  • 如果是点击其他子应用或父应用触发全局数据变更,则当前打开的子应用获取到的 app 为 null

  • 所以需要改成用 activeRule 来判断当前子应用是否运行

*/

const app = getApp(appName) as Application

if (!(isActive(app) && app.status === AppStatus.MOUNTED)) return

callbacks.forEach(callback => callback(this.state, operator, key))

})

}

}

下面是实现了第二点要求的部分关键代码:

export default class EventBus {

private eventsMap: Map<string, Record<string, Array>> = new Map()

on(event: string, callback: Callback) {

if (!isFunction(callback)) {

throw Error(The second param ${typeof callback} is not a function)

}

const appName = getCurrentAppName() || ‘parent’

const { eventsMap } = this

if (!eventsMap.get(appName)) {

eventsMap.set(appName, {})

}

const events = eventsMap.get(appName)!

if (!events[event]) {
自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。

深知大多数前端工程师,想要提升技能,往往是自己摸索成长或者是报班学习,但对于培训机构动则几千的学费,着实压力不小。自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!

因此收集整理了一份《2024年Web前端开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。

img

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上前端开发知识点,真正体系化!

由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!

如果你觉得这些内容对你有帮助,可以扫码获取!!(备注:前端)

最后

今天的文章可谓是积蓄了我这几年来的应聘和面试经历总结出来的经验,干货满满呀!如果你能够一直坚持看到这儿,那么首先我还是十分佩服你的毅力的。不过光是看完而不去付出行动,或者直接进入你的收藏夹里吃灰,那么我写这篇文章就没多大意义了。所以看完之后,还是多多行动起来吧!

可以非常负责地说,如果你能够坚持把我上面列举的内容都一个不拉地看完并且全部消化为自己的知识的话,那么你就至少已经达到了中级开发工程师以上的水平,进入大厂技术这块是基本没有什么问题的了。

te, operator, key))

})

}

}

下面是实现了第二点要求的部分关键代码:

export default class EventBus {

private eventsMap: Map<string, Record<string, Array>> = new Map()

on(event: string, callback: Callback) {

if (!isFunction(callback)) {

throw Error(The second param ${typeof callback} is not a function)

}

const appName = getCurrentAppName() || ‘parent’

const { eventsMap } = this

if (!eventsMap.get(appName)) {

eventsMap.set(appName, {})

}

const events = eventsMap.get(appName)!

if (!events[event]) {
自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。

深知大多数前端工程师,想要提升技能,往往是自己摸索成长或者是报班学习,但对于培训机构动则几千的学费,着实压力不小。自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!

因此收集整理了一份《2024年Web前端开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。

[外链图片转存中…(img-IgQQoKam-1713837946566)]

[外链图片转存中…(img-Ccm41KN8-1713837946567)]

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上前端开发知识点,真正体系化!

[外链图片转存中…(img-ib3WqsPK-1713837946567)]

由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!

如果你觉得这些内容对你有帮助,可以扫码获取!!(备注:前端)

[外链图片转存中…(img-QtSjxDon-1713837946567)]

最后

今天的文章可谓是积蓄了我这几年来的应聘和面试经历总结出来的经验,干货满满呀!如果你能够一直坚持看到这儿,那么首先我还是十分佩服你的毅力的。不过光是看完而不去付出行动,或者直接进入你的收藏夹里吃灰,那么我写这篇文章就没多大意义了。所以看完之后,还是多多行动起来吧!

可以非常负责地说,如果你能够坚持把我上面列举的内容都一个不拉地看完并且全部消化为自己的知识的话,那么你就至少已经达到了中级开发工程师以上的水平,进入大厂技术这块是基本没有什么问题的了。

资料领取方式:戳这里前往免费领取

  • 24
    点赞
  • 25
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值