最后
一个好的心态和一个坚持的心很重要,很多冲着高薪的人想学习前端,但是能学到最后的没有几个,遇到困难就放弃了,这种人到处都是,就是因为有的东西难,所以他的回报才很大,我们评判一个前端开发者是什么水平,就是他解决问题的能力有多强。
开源分享:【大厂前端面试题解析+核心总结学习笔记+真实项目实战+最新讲解视频】
分享一些前端面试题以及学习路线给大家
export default function overwriteEventsAndHistory() {
window.history.pushState = function (state: any, title: string, url: string) {
const result = originalPushState.call(this, state, title, url)
// 根据当前 url 加载或卸载 app
loadApps()
return result
}
window.history.replaceState = function (state: any, title: string, url: string) {
const result = originalReplaceState.call(this, state, title, url)
loadApps()
return result
}
window.addEventListener(‘popstate’, () => {
loadApps()
}, true)
window.addEventListener(‘hashchange’, () => {
loadApps()
}, true)
}
从上面的代码可以看出来,每次 URL 改变时,都会调用 loadApps()
方法,这个方法的作用就是根据当前的 URL、子应用的触发规则去切换子应用的状态:
export async function loadApps() {
// 先卸载所有失活的子应用
const toUnMountApp = getAppsWithStatus(AppStatus.MOUNTED)
await Promise.all(toUnMountApp.map(unMountApp))
// 初始化所有刚注册的子应用
const toLoadApp = getAppsWithStatus(AppStatus.BEFORE_BOOTSTRAP)
await Promise.all(toLoadApp.map(bootstrapApp))
const toMountApp = [
…getAppsWithStatus(AppStatus.BOOTSTRAPPED),
…getAppsWithStatus(AppStatus.UNMOUNTED),
]
// 加载所有符合条件的子应用
await toMountApp.map(mountApp)
}
这段代码的逻辑也比较简单:
1.卸载所有已失活的子应用2.初始化所有刚注册的子应用3.加载所有符合条件的子应用
根据当前 URL、子应用的触发规则来判断是否要加载、卸载子应用
为了支持不同框架的子应用,所以规定了子应用必须向外暴露 bootstrap()
mount()
unmount()
这三个方法。bootstrap()
方法在第一次加载子应用时触发,并且只会触发一次,另外两个方法在每次加载、卸载子应用时都会触发。
不管注册的是什么子应用,在 URL 符合加载条件时就调用子应用的 mount()
方法,能不能正常渲染交给子应用负责。在符合卸载条件时则调用子应用的 unmount()
方法。
registerApplication({
name: ‘vue’,
// 初始化子应用时执行该方法
loadApp() {
return {
mount() {
// 这里进行挂载子应用的操作
app.mount(‘#app’)
},
unmount() {
// 这里进行卸载子应用的操作
app.unmount()
},
}
},
// 如果传入一个字符串会被转为一个参数为 location 的函数
// activeRule: ‘/vue’ 会被转为 (location) => location.pathname === ‘/vue’
activeRule: (location) => location.hash === ‘#/vue’
})
上面是一个简单的子应用注册示例,其中 activeRule()
方法用来判断该子应用是否激活(返回 true
表示激活)。每当页面 URL 发生变化,微前端框架就会调用 loadApps()
判断每个子应用是否激活,然后触发加载、卸载子应用的操作。
何时加载、卸载子应用
首先我们将子应用的状态分为三种:
•bootstrap
,调用 registerApplication()
注册一个子应用后,它的状态默认为 bootstrap
,下一个转换状态为 mount
。•mount
,子应用挂载成功后的状态,它的下一个转换状态为 unmount
。•unmount
,子应用卸载成功后的状态,它的下一个转换状态为 mount
,即卸载后的应用可再次加载。
现在我们来看看什么时候会加载一个子应用,当页面 URL 改变后,如果子应用满足以下两个条件,则需要加载该子应用:
1.activeRule()
的返回值为 true
,例如 URL 从 /
变为 /vue
,这时子应用 vue 为激活状态(假设它的激活规则为 /vue
)。2.子应用状态必须为 bootstrap
或 unmount
,这样才能向 mount
状态转换。如果已经处于 mount
状态并且 activeRule()
返回值为 true
,则不作任何处理。
如果页面的 URL 改变后,子应用满足以下两个条件,则需要卸载该子应用:
1.activeRule()
的返回值为 false
,例如 URL 从 /vue
变为 /
,这时子应用 vue 为失活状态(假设它的激活规则为 /vue
)。2.子应用状态必须为 mount
,也就是当前子应用必须处于加载状态(如果是其他状态,则不作任何处理)。然后 URL 改变导致失活了,所以需要卸载它,状态也从 mount
变为 unmount
。
API 介绍
V1 版本主要向外暴露了两个 API:
1.registerApplication()
,注册子应用。2.start()
,注册完所有的子应用后调用,在它的内部会执行 loadApps()
去加载子应用。
registerApplication(Application)
接收的参数如下:
interface Application {
// 子应用名称
name: string
/**
-
激活规则,例如传入 /vue,当 url 的路径变为 /vue 时,激活当前子应用。
-
如果 activeRule 为函数,则会传入 location 作为参数,activeRule(location) 返回 true 时,激活当前子应用。
*/
activeRule: Function | string
// 传给子应用的自定义参数
props: AnyObject
/**
-
loadApp() 必须返回一个 Promise,resolve() 后得到一个对象:
-
{
-
bootstrap: () => Promise
-
mount: (props: AnyObject) => Promise
-
unmount: (props: AnyObject) => Promise
-
}
*/
loadApp: () => Promise
}
一个完整的示例
现在我们来看一个比较完整的示例(代码在 V1 分支的 examples 目录):
let vueApp
registerApplication({
name: ‘vue’,
loadApp() {
return Promise.resolve({
bootstrap() {
console.log(‘vue bootstrap’)
},
mount() {
console.log(‘vue mount’)
vueApp = Vue.createApp({
data() {
return {
text: ‘Vue App’
}
},
render() {
return Vue.h(
‘div’, // 标签名称
this.text // 标签内容
)
},
})
vueApp.mount(‘#app’)
},
unmount() {
console.log(‘vue unmount’)
vueApp.unmount()
},
})
},
activeRule:(location) => location.hash === ‘#/vue’,
})
registerApplication({
name: ‘react’,
loadApp() {
return Promise.resolve({
bootstrap() {
console.log(‘react bootstrap’)
},
mount() {
console.log(‘react mount’)
ReactDOM.render(
React.createElement(LikeButton),
$(‘#app’)
);
},
unmount() {
console.log(‘react unmount’)
ReactDOM.unmountComponentAtNode($(‘#app’));
},
})
},
activeRule: (location) => location.hash === ‘#/react’
})
start()
演示效果如下:
小结
V1 版本的代码打包后才 100 多行,如果只是想了解微前端的最核心原理,只看 V1 版本的源码就可以了。
V2 版本
V1 版本的实现还是非常简陋的,能够适用的业务场景有限。从 V1 版本的示例可以看出,它要求子应用提前把资源都加载好(或者把整个子应用打包成一个 NPM 包,直接引入),这样才能在执行子应用的 mount()
方法时,能够正常渲染。
举个例子,假设我们在开发环境启动了一个 vue 应用。那么如何在主应用引入这个 vue 子应用的资源呢?首先排除掉 NPM 包的形式,因为每次修改代码都得打包,不现实。第二种方式就是手动在主应用引入子应用的资源。例如 vue 子应用的入口资源为:
那么我们可以在注册子应用时这样引入:
registerApplication({
name: ‘vue’,
loadApp() {
return Promise.resolve({
bootstrap() {
import(‘http://localhost:8001/js/chunk-vendors.js’)
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 内容。上图就是一个 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()
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
JavaScript 和 ES6
在这个过程你会发现,有很多 JS 知识点你并不能更好的理解为什么这么设计,以及这样设计的好处是什么,这就逼着让你去学习这单个知识点的来龙去脉,去哪学?第一,书籍,我知道你不喜欢看,我最近通过刷大厂面试题整理了一份前端核心知识笔记,比较书籍更精简,一句废话都没有,这份笔记也让我通过跳槽从8k涨成20k。
如果你觉得对你有帮助,可以戳这里获取:【大厂前端面试题解析+核心总结学习笔记+真实项目实战+最新讲解视频】
strap() { // … }
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
JavaScript 和 ES6
在这个过程你会发现,有很多 JS 知识点你并不能更好的理解为什么这么设计,以及这样设计的好处是什么,这就逼着让你去学习这单个知识点的来龙去脉,去哪学?第一,书籍,我知道你不喜欢看,我最近通过刷大厂面试题整理了一份前端核心知识笔记,比较书籍更精简,一句废话都没有,这份笔记也让我通过跳槽从8k涨成20k。