前言
大家好这里是阳九,一个中途转行的野路子码农,热衷于研究和手写前端工具.
我的宗旨就是 万物皆可手写
新手创作不易,有问题欢迎指出和轻喷,谢谢
本文章适合使用过微前端技术的开发人员,如果没有请查阅microApp官方文档
本人也有TS手写的简易版microApp框架,注释齐全可供学习.
官网链接: micro-zoe.github.io/micro-app/
手写简易microApp视频展示:原生TS手写京东microApp微前端框架 + microApp源码导读_哔哩哔哩_bilibili
手写简易microApp仓库地址:lzy19926/lzy-microApp: 简易microApp微前端框架实现(github.com)
前置知识 webComponent
webComponent
microApp是京东推出的一款轻量级微前端框架(解决方案),使用webComponent
的思想去实现。
我们知道,html中有许多标签,div,p,span
等等,这些标签渲染出的都是html元素。
我更愿意将webComponent叫做自定义html元素, 他的实现思路很简单,也就是让用户通过js代码自定义一个htmlElement,并注册到document中, 之后便可使用标签。
创建一个自定义元素
class CustomEle extends HTMLElement {
constructor() {
super();
console.log('创建了自定义标签')
}
}
// 注册自定义元素为标签
customElements.define('custom-ele', CustomEle);
// 在html中使用
<body>
<custom-ele/>
</body>
// createElement时会执行new CustomEle
const customEle = document.createElement('custom-ele');
对自定义元素进行操作
比如我们想将name属性用p标签渲染到标签内 我们可以这样做
class CustomEle extends HTMLElement {
constructor() {
super();
const name = this.getAttribute('name') || '';
this.innerHTML=`<p>{name}</p>` // 或者其他操作dom的方法
}
}
// 在html中使用
<body>
<custom-ele name="张三"/>
</body>
之后我们就可以在页面中渲染出一个张三
通过这种方式,我们可以使用一个类轻松定制出一个即插即用的组件,跨平台,跨框架。
webComponent的生命周期
由于CustomEle的构造函数只会在其创建时执行一次, 我们需要他的生命周期以及钩子函数来帮助我们完成其他的操作
class CustomEle extends HTMLElement {
constructor() {
super();
console.log('创建了自定义标签')
}
connectedCallback() {}// 组件被成功添加到主文档时触发
disconnectedCallback() {} //组件从主文档移除时触发
adoptedCallback() {} // 元素被移动到新的文档时调用,(不常用)
// 监听组件属性,用于触发attributeChangedCallback
static get observedAttributes() { return ['img', 'text']; }
attributeChangedCallback() {} // 增删改被监听的组件属性时触发
}
沙箱与shadowDom
可参考我的文章 微前端乾坤框架 CSS JS沙箱隔离环境原理 - 掘金 (juejin.cn)
MicroApp类
MicroApp的外层类, 使用者通过start方法启动microApp
microApp = new MicroApp()
microApp.start()
START方法中主要做了三件事
- 挂载定义好的app操作函数
- initGlobalEnv() 初始化全局变量
- defineElement(this.tagName) 定义custom-element
源码摘要:
export class MicroApp extends EventCenterForBaseApp implements MicroAppBaseType {
tagName = 'micro-app'
options: OptionsType = {}
// 挂载定义好的app操作函数
preFetch = preFetch
unmountApp = unmountApp
unmountAllApps = unmountAllApps
getActiveApps = getActiveApps
getAllApps = getAllApps
reload = reload
renderApp = renderApp
// start方法
start (options?: OptionsType): void {
...
initGlobalEnv()
...
defineElement(this.tagName)
}
}
initGlobalEnv 初始化全局变量
首先这里定义了原始window,document等, 并将一些原始方法从Element中取出并保存,以便以后可以直接从globalENV中获取到原始方法
其作用是为了服务沙箱, 沙箱内需要修改一些方法,比如window.getElementById
等
export function initGlobalEnv (): void {
if (isBrowser) {
const rawWindow = Function('return window')()
const rawDocument = Function('return document')()
const rawRootDocument = Function('return Document')()
/**
* save patch raw methods
* pay attention to this binding
*/
// 将一些Element上的原始方法取出保存
const rawSetAttribute = Element.prototype.setAttribute
const rawAppendChild = Element.prototype.appendChild
const rawRemoveChild = Element.prototype.removeChild
...
// 将一些document上的原始方法取出保存
const rawCreateElement = rawRootDocument.prototype.createElement
const rawQuerySelector = rawRootDocument.prototype.querySelector
const rawGetElementById = rawRootDocument.prototype.getElementById
// 代理Image元素
const ImageProxy = new Proxy(Image, {...})
/**
* save effect raw methods
* pay attention to this binding, especially setInterval, setTimeout, clearInterval, clearTimeout
*/
// 将window原始方法拿出来保存 比如addEventListener setInterval setTimeout等
const rawWindowAddEventListener = rawWindow.addEventListener
const rawSetInterval = rawWindow.setInterval
const rawSetTimeout = rawWindow.setTimeout
...
// 将document原始方法拿出来保存 addEventListener removeEventListener
const rawDocumentAddEventListener = rawDocument.addEventListener
const rawDocumentRemoveEventListener = rawDocument.removeEventListener
// 修改全局变量,表示baseApp运行
window.__MICRO_APP_BASE_APPLICATION__ = true
// 把以上方法用Object.assign合并到globalEnv对象中
assign(globalEnv, {...})
// 给baseApp设置一个初始head body样式
// micro-app-body { display: block; } ; micro-app-head { display: none; }
rejectMicroAppStyle()
}
}
MicroAppElement类
我们知道 start中主要会defineElement() 定义并创建一个MicroAppElement实例,也就是前文所说的webComponent,自定义元素
export function defineElement (tagName: string): void {
// 定义自定义元素
class MicroAppElement extends HTMLElement implements MicroAppElementType {
// 监视标签中的'name', 'url'属性 改变时触发回调,进行connect
static get observedAttributes (): string[] {
return ['name', 'url']
}
......
}
// 注册元素(这里tagName初始就是"micro-app")
globalEnv.rawWindow.customElements.define(tagName, MicroAppElement)
}
handleConnect链接app 当我们设置元素的name和url后, 元素会首先触发attributeChangedCallback,执行handleInitialNameAndUrl方法 而后执行handleConnect
- handleConnect中会初始化shadowDOM,updateSsrUrl,对KeepAliveApp做处理等等
- 最终会执行handleCreateApp
handleCreateApp创建App实例
// create app instance
private handleCreateApp (): void {
// 如果有app存在先销毁app
if (appInstanceMap.has(this.appName)) {
appInstanceMap.get(this.appName)!.actionsForCompletelyDestroy()
}
new CreateApp({
name: this.appName,
url: this.appUrl,
scopecss: this.isScopecss(),
useSandbox: this.isSandbox(),
inline: this.getDisposeResult('inline'),
esmodule: this.getDisposeResult('esmodule'),
container: this.shadowRoot ?? this,
ssrUrl: this.ssrUrl,
})
}
CreateApp类
App核心类 用于创建一个App实例
获取app资源
跟single-SPA一样 首先我们应该获取一个微前端应用的三大资源 js css html
class CreateApp implements AppInterface {
constructor(){
...
this.loadSourceCode()
}
public loadSourceCode (): void {
this.state = appStates.LOADING
HTMLLoader.getInstance().run(this, extractSourceDom) // run获取资源
}
}
我们创建一个htmlLoader并执行run方法
提一嘴htmlLoader的单例模式,这里使用HTMLLoader.getInstance()方法获取单例,保证获取对象的唯一性
export class HTMLLoader implements IHTMLLoader {
private static instance: HTMLLoader;
public static getInstance (): HTMLLoader {
if (!this.instance) {
this.instance = new HTMLLoader()
}
return this.instance
}
...
}
通过简单的fetch方法即可通过url "localhost:3000" 获取到html字符串
window.fetch(url, options).then((res: Response) => {
return res.text()
})
传入的extractSourceDom方法作为html字符串的回调,获取对应的script和links
export function extractSourceDom (htmlStr: string, app: AppInterface): void {
if (app.source.links.size) {
fetchLinksFromHtml(wrapElement, app, microAppHead, fiberStyleResult) // fetchLinks
}
...
if (app.source.scripts.size) {
fetchScriptsFromHtml(wrapElement, app) // fetchScripts
}
...
}
检测资源是否获取完毕
团队封装了PromiseStream方法来获取scripts和links(一个html中往往有多个脚本和样式)
这是一个用于瀑布流式执行(one by one)多个Promise的函数
export function promiseStream <T> (
promiseList: Array<Promise<T> | T>,
successCb: CallableFunction,
errorCb: CallableFunction,
finallyCb?: CallableFunction,
): void {
...
}
我们可以看到 调用次函数传入的finallyCb中 一定会执行app.onload方法
promiseStream<string>(
promiseList,
successCb,
errorCb,
() => {
if (fiberLinkTasks) {
fiberStyleResult!.then(() => {
fiberLinkTasks.push(() => Promise.resolve(app.onLoad(wrapElement))) // resolve执行onload
serialExecFiberTasks(fiberLinkTasks)
})
} else {
app.onLoad(wrapElement) // 直接执行onload
}
})
)
将fetch来的资源保存到sourceCenter中
使用sourceCenter
来缓存获取的资源,以便复用。
在extractLinkFromHtml
中我们可以看到,将link代码包装为linkInfo后存入sourceCenter
,
当然script同理
export function extractLinkFromHtml(){
...
sourceCenter.link.setInfo(href, linkInfo)
}
App.onload
我们可以看到,只有第三次触发onload方法,才会真正开始执行, 也就是当links和scripts成功获取并执行对应finally回调后,才会执行。
public onLoad (
html: HTMLElement,
defaultPage?: string,
disablePatchRequest?: boolean,
): void {
if(++this.loadSourceLevel === 2){// 每次执行onload++
// 非preFetch时,直接获取container,执行mount方法
if (!this.isPrefetch && appStates.UNMOUNT !== this.state) {
getRootContainer(this.container!).mount(this)
}
// preFetch时, 创建一个div作为container,执行mount方法
else if (this.isPrerender) {
const container = pureCreateElement('div')
container.setAttribute('prerender', 'true')
this.sandBox?.setPreRenderState(true)
this.mount({
container,
inline: this.inline,
useMemoryRouter: true,
baseroute: '',
fiber: true,
esmodule: this.esmodule,
defaultPage: defaultPage ?? '',
disablePatchRequest: disablePatchRequest ?? false,
})
}
}
}
mount
mount中我们会
- 测试shadowDom
- 开启沙箱 this.sandBox?.start
- 执行脚本 execScripts -->runScript
execScripts中执行的代码从sourceCenter中获取到的资源,需要在之前提供的沙箱中执行。
至此,js代码执行完毕。 页面上就能正常的渲染出一个页面
沙箱 patch与releasePatch
微应用作用与沙箱环境中,在进入沙箱时,我们需要修改document,Element上的诸多dom操作方法。
还记得之前在init时保存到globalEnv中的原始方法吗?现在起作用了
- patch: 可以理解为:修改方法
- releasePatch: 将修改的原生方法还原
为什么要修改原生方法? 这里主要做两件事
-
修改this指向 : 如果当前document是Proxy代理后的document,则this指向原始document,如果不是则this指向当前document
-
给创建的元素做标记
我们以microApp中的patchDocument方法举例
function patchDocument () {
// 从globalEnv中获取原始document
const rawDocument = globalEnv.rawDocument
const rawRootDocument = globalEnv.rawRootDocument
// 获取需要指向的this
function getBindTarget (target: Document): Document {
return isProxyDocument(target) ? rawDocument : target
}
// 给创建的element打上标记
function markElement <T extends { __MICRO_APP_NAME__: string }> (element: T): T {
const currentAppName = getCurrentAppName()
if (currentAppName) element.__MICRO_APP_NAME__ = currentAppName
return element
}
// 修改rawRootDocument.prototype.createElement上的createElement方法
rawRootDocument.prototype.createElement = function createElement (
tagName: string,
options?: ElementCreationOptions
): HTMLElement
{
const element = globalEnv.rawCreateElement.call(getBindTarget(this), tagName, options)
return markElement(element)
}
// 后面还修改了很多dom操作方法 如
rawRootDocument.prototype.createElementNS = function createElementNS(){...}
rawRootDocument.prototype.createDocumentFragment = function createDocumentFragment(){...}
rawRootDocument.prototype.querySelector = function querySelector(){...}
rawRootDocument.prototype.querySelectorAll = function querySelectorAll(){...}
rawRootDocument.prototype.getElementById = function getElementById(){...}
rawRootDocument.prototype.getElementsByClassName = function getElementsByClassName(){...}
...
}
同样的 对于Element对象上操作dom方法也进行了修改,并将对应的操作封装到了commonElementHandler方法里
release patch
将修改过的方法还原
(当然我觉得这里应该抽出patchElement和releasePatchElement函数,感觉层级不太对劲)
patchAttrbuilt需要在MicroAppElement创建时执行,做特殊处理
// release patch
export function releasePatches (): void {
removeDomScope()
releasePatchDocument() // 还原document方法
// 还原Element方法
Element.prototype.appendChild = globalEnv.rawAppendChild
Element.prototype.insertBefore = globalEnv.rawInsertBefore
Element.prototype.replaceChild = globalEnv.rawReplaceChild
Element.prototype.removeChild = globalEnv.rawRemoveChild
Element.prototype.append = globalEnv.rawAppend
Element.prototype.prepend = globalEnv.rawPrepend
Element.prototype.cloneNode = globalEnv.rawCloneNode
Element.prototype.querySelector = globalEnv.rawElementQuerySelector
Element.prototype.querySelectorAll = globalEnv.rawElementQuerySelectorAll
//DefineProperty 方法特殊处理
rawDefineProperty(Element.prototype, 'innerHTML', globalEnv.rawInnerHTMLDesc)
}
后记
微前端的其他原理比如qiankun实现原理,沙箱的实现,等可以参考我的文章
手把手带你手写一个qiankun 微前端核心原理 - 掘金 (juejin.cn)
微前端乾坤框架 CSS JS沙箱隔离环境原理 - 掘金 (juejin.cn)
本人也有简易版microApp框架的实现,代码可用,注释详细,
lzy19926/lzy-microApp: 简易microApp微前端框架实现(github.com) 需要学习的小伙伴可以給个star嘛,bug很多 凑合看把