refactor(core): CORE-04 scope initialization and make destruction reentrant-safe

This commit is contained in:
Harvey Zhao committed 2026-09-11 00:43:49 +08:00
1 parent 3e38dd0d19
commit 4eec8112fa
32 files changed
+2779 -189

No files matched your search

+5 -2
View File
@@ -1,3 +1,5 @@
import { getScope } from '../lifecycle/instance'
import { wait } from '../lifecycle/resources'
import {
addClass,
append,
@@ -9,7 +11,6 @@ import {
inverseClass,
isMobile,
removeClass,
sleep,
} from '../utils'
import Component from '../utils/component'
import airplay from './airplay'
@@ -127,7 +128,9 @@ export default class Control extends Component {
}
if (option.quality.length) {
sleep().then(() => {
wait(getScope(this.art)).then((active) => {
if (!active || getScope(this.art).closed)
return
this.art.quality = option.quality
})
}
+9
View File
@@ -1,3 +1,4 @@
import { getScope } from '../lifecycle/instance'
import clickInit from './clickInit'
import gestureInit from './gestureInit'
import globalInit from './globalInit'
@@ -7,9 +8,14 @@ import resizeInit from './resizeInit'
import updateInit from './updateInit'
import viewInit from './viewInit'
const scopes = new WeakMap()
export default class Events {
constructor(art) {
this.destroyEvents = new Set()
const scope = getScope(art)
scopes.set(this, scope)
scope.add(() => this.destroy())
this.proxy = this.proxy.bind(this)
this.hover = this.hover.bind(this)
@@ -28,6 +34,9 @@ export default class Events {
return name.map(item => this.proxy(target, item, callback, option))
}
if (scopes.get(this).closed)
return () => {}
target.addEventListener(name, callback, option)
const destroy = () => target.removeEventListener(name, callback, option)
this.destroyEvents.add(destroy)
+8 -2
View File
@@ -1,4 +1,5 @@
import { debounce } from '../utils'
import { getScope } from '../lifecycle/instance'
import { timeout } from '../lifecycle/resources'
export default function resizeInit(art, events) {
const { option, constructor } = art
@@ -12,7 +13,12 @@ export default function resizeInit(art, events) {
notice.show = ''
})
const resizeFn = debounce(() => art.emit('resize'), constructor.RESIZE_TIME)
const scope = getScope(art)
let cancel = () => {}
const resizeFn = () => {
cancel()
cancel = timeout(scope, () => art.emit('resize'), constructor.RESIZE_TIME)
}
art.on('window:orientationchange', () => resizeFn())
art.on('window:resize', () => resizeFn())
+7 -3
View File
@@ -1,6 +1,10 @@
import { getScope } from '../lifecycle/instance'
import { animationFrame } from '../lifecycle/resources'
export default function updateInit(art) {
if (art.constructor.USE_RAF) {
let timer = null;
const scope = getScope(art)
let cancel = () => {};
(function update() {
if (art.playing) {
@@ -8,12 +12,12 @@ export default function updateInit(art) {
}
if (!art.isDestroy) {
timer = requestAnimationFrame(update)
cancel = animationFrame(scope, update)
}
})()
art.on('destroy', () => {
cancelAnimationFrame(timer)
cancel()
})
}
}
+46 -36
View File
@@ -9,6 +9,7 @@ import I18n from './i18n'
import Icons from './icons'
import Info from './info'
import Layer from './layer'
import { beginLifecycle, destroyInstance, finishLifecycle, getScope } from './lifecycle/instance'
import Loading from './loading'
import Mask from './mask'
import Notice from './notice'
@@ -47,38 +48,54 @@ export default class Artplayer extends Emitter {
this.isRotate = false
this.isDestroy = false
this.template = new Template(this)
this.events = new Events(this)
this.storage = new Storage(this)
this.icons = new Icons(this)
this.i18n = new I18n(this)
this.notice = new Notice(this)
this.player = new Player(this)
this.layers = new Layer(this)
this.controls = new Control(this)
this.contextmenu = new Contextmenu(this)
this.subtitle = new Subtitle(this)
this.info = new Info(this)
this.loading = new Loading(this)
this.hotkey = new Hotkey(this)
this.mask = new Mask(this)
this.setting = new Setting(this)
this.plugins = new Plugins(this)
beginLifecycle(this)
try {
this.template = new Template(this)
this.events = new Events(this)
this.storage = new Storage(this)
this.icons = new Icons(this)
this.i18n = new I18n(this)
this.notice = new Notice(this)
this.player = new Player(this)
this.layers = new Layer(this)
this.controls = new Control(this)
this.contextmenu = new Contextmenu(this)
this.subtitle = new Subtitle(this)
this.info = new Info(this)
this.loading = new Loading(this)
this.hotkey = new Hotkey(this)
this.mask = new Mask(this)
this.setting = new Setting(this)
this.plugins = new Plugins(this)
if (typeof readyCallback === 'function') {
this.on('ready', () => readyCallback.call(this, this))
}
if (getScope(this).closed)
return
if (Artplayer.DEBUG) {
// eslint-disable-next-line no-console
const log = msg => console.log(`[ART.${this.id}] -> ${msg}`)
log(`Version@${Artplayer.version}`)
for (let index = 0; index < config.events.length; index++) {
this.on(`video:${config.events[index]}`, event => log(`Event@${event.type}`))
if (typeof readyCallback === 'function') {
this.on('ready', () => readyCallback.call(this, this))
}
}
instances.push(this)
if (Artplayer.DEBUG) {
// eslint-disable-next-line no-console
const log = msg => console.log(`[ART.${this.id}] -> ${msg}`)
log(`Version@${Artplayer.version}`)
for (let index = 0; index < config.events.length; index++) {
this.on(`video:${config.events[index]}`, event => log(`Event@${event.type}`))
}
}
if (finishLifecycle(this))
instances.push(this)
}
catch (error) {
try {
destroyInstance(this, instances, true, Artplayer.REMOVE_SRC_WHEN_DESTROY, true)
}
catch (cleanupError) {
console.warn('Failed to clean up ArtPlayer initialization:', cleanupError)
}
throw error
}
}
static get instances() {
@@ -208,14 +225,7 @@ export default class Artplayer extends Emitter {
}
destroy(removeHtml = true) {
if (Artplayer.REMOVE_SRC_WHEN_DESTROY) {
this.reset()
}
this.events.destroy()
this.template.destroy(removeHtml)
instances.splice(instances.indexOf(this), 1)
this.isDestroy = true
this.emit('destroy')
destroyInstance(this, instances, removeHtml, Artplayer.REMOVE_SRC_WHEN_DESTROY)
}
}
+6 -3
View File
@@ -1,3 +1,5 @@
import { getScope } from './lifecycle/instance'
import { timeout } from './lifecycle/resources'
import { isMobile, queryAll } from './utils'
import Component from './utils/component'
@@ -22,9 +24,10 @@ export default class Info extends Component {
this.show = false
})
let timer = null
let cancel = () => {}
const $types = queryAll('[data-video]', $infoPanel) || []
this.art.on('destroy', () => clearTimeout(timer))
this.art.on('destroy', () => cancel())
const scope = getScope(this.art)
function loop() {
for (let index = 0; index < $types.length; index++) {
@@ -35,7 +38,7 @@ export default class Info extends Component {
item.textContent = textContent
}
}
timer = setTimeout(loop, constructor.INFO_LOOP_TIME)
cancel = timeout(scope, loop, constructor.INFO_LOOP_TIME)
}
loop()
@@ -0,0 +1,91 @@
import ResourceScope, { ResourceCleanupError } from './scope'
interface LifecycleOwner {
isDestroy: boolean
reset: () => void
emit: (name: 'destroy') => unknown
template?: { $video?: unknown, destroy: (removeHtml: boolean) => void }
}
interface State {
scope: ResourceScope
destroying: boolean
rollback?: () => void
releaseContainer?: () => void
}
const states = new WeakMap<object, State>()
const containers = new WeakMap<Element, object>()
export function beginLifecycle(owner: object): void {
states.set(owner, { scope: new ResourceScope(), destroying: false })
}
function stateOf(owner: object): State {
const state = states.get(owner)
if (!state)
throw new Error('ArtPlayer lifecycle has not been initialized')
return state
}
export function getScope(owner: object): ResourceScope {
return stateOf(owner).scope
}
export function ownContainer(owner: object, container: Element, rollback: () => void): void {
const current = containers.get(container)
if (current && current !== owner)
throw new Error('Cannot mount multiple instances on the same dom element')
const state = stateOf(owner)
state.rollback = rollback
containers.set(container, owner)
state.releaseContainer = () => {
if (containers.get(container) === owner)
containers.delete(container)
}
}
export function finishLifecycle(owner: object): boolean {
const state = stateOf(owner)
state.rollback = undefined
return !state.scope.closed
}
export function destroyInstance(owner: LifecycleOwner, instances: LifecycleOwner[], removeHtml: boolean, removeSource: boolean, failed = false): void {
const state = stateOf(owner)
if (state.destroying || state.scope.closed)
return
state.destroying = true
const errors: unknown[] = []
const attempt = (cleanup: () => void) => {
try {
cleanup()
}
catch (error) {
errors.push(...(error instanceof ResourceCleanupError ? error.errors : [error]))
}
}
if (removeSource && owner.template?.$video)
attempt(() => owner.reset())
attempt(() => state.scope.dispose())
attempt(() => owner.template?.destroy(removeHtml))
const index = instances.indexOf(owner)
if (index !== -1)
instances.splice(index, 1)
owner.isDestroy = true
if (!failed) {
state.releaseContainer?.()
state.releaseContainer = undefined
}
attempt(() => owner.emit('destroy'))
if (failed && state.rollback)
attempt(state.rollback)
state.rollback = undefined
state.releaseContainer?.()
state.releaseContainer = undefined
// Preserve the first thrown value while making additional failures observable.
for (const error of errors.slice(1))
console.warn('Additional ArtPlayer cleanup failure:', error)
if (errors.length)
throw errors[0]
}
@@ -83,3 +83,22 @@ export function objectURL(scope: ResourceScope, blob: Blob): string {
})
return url
}
export function wait(scope: ResourceScope, delay = 0): Promise<boolean> {
if (scope.closed)
return Promise.resolve(false)
return new Promise((resolve) => {
let completed = false
let release = () => {}
const timer = setTimeout(() => {
completed = true
release()
resolve(true)
}, delay)
release = scope.add(() => {
clearTimeout(timer)
if (!completed)
resolve(false)
})
})
}
@@ -0,0 +1,39 @@
interface Snapshot {
node: Node
children: Node[]
attributes?: [string, string][]
value: string | null
}
export default function captureTemplate(container: Element): () => void {
const snapshots: Snapshot[] = []
function capture(node: Node) {
const children = Array.from(node.childNodes)
snapshots.push({
node,
children,
attributes: node.nodeType === 1 ? Array.from((node as Element).attributes, attr => [attr.name, attr.value]) : undefined,
value: node.nodeValue,
})
children.forEach(capture)
}
capture(container)
return () => {
for (const { node, children, attributes, value } of snapshots) {
while (node.firstChild)
node.removeChild(node.firstChild)
for (const child of children)
node.appendChild(child)
if (attributes) {
const element = node as Element
for (const attr of Array.from(element.attributes))
element.removeAttribute(attr.name)
for (const [name, content] of attributes)
element.setAttribute(name, content)
}
else {
node.nodeValue = value
}
}
}
}
+4
View File
@@ -1,9 +1,11 @@
import { getScope } from './lifecycle/instance'
import { addClass, removeClass } from './utils'
export default class Notice {
constructor(art) {
this.art = art
this.timer = null
getScope(art).add(() => this.destroy())
art.on('destroy', () => this.destroy())
}
@@ -16,6 +18,8 @@ export default class Notice {
}
set show(msg) {
if (getScope(this.art).closed)
return
const {
constructor,
template: { $player, $noticeInner },
+7 -3
View File
@@ -1,5 +1,7 @@
import config from '../config'
import { addClass, isMobile, setStyle, silencePromise, sleep } from '../utils'
import { getScope } from '../lifecycle/instance'
import { wait } from '../lifecycle/resources'
import { addClass, isMobile, setStyle, silencePromise } from '../utils'
export default function eventInit(art) {
const {
@@ -63,7 +65,8 @@ export default function eventInit(art) {
art.on('video:error', async (error) => {
if (reconnectTime < constructor.RECONNECT_TIME_MAX) {
await sleep(constructor.RECONNECT_SLEEP_TIME)
if (!await wait(getScope(art), constructor.RECONNECT_SLEEP_TIME) || getScope(art).closed)
return
reconnectTime += 1
art.url = option.url
notice.show = `${i18n.get('Reconnect')}: ${reconnectTime}`
@@ -74,7 +77,8 @@ export default function eventInit(art) {
art.loading.show = false
art.controls.show = true
addClass($player, 'art-error')
await sleep(constructor.RECONNECT_SLEEP_TIME)
if (!await wait(getScope(art), constructor.RECONNECT_SLEEP_TIME) || getScope(art).closed)
return
notice.show = i18n.get('Video Load Failed')
}
})
+7 -3
View File
@@ -1,4 +1,6 @@
import { def, getExt, sleep } from '../utils'
import { getScope } from '../lifecycle/instance'
import { wait } from '../lifecycle/resources'
import { def, getExt } from '../utils'
export default function urlMix(art) {
const {
@@ -17,7 +19,8 @@ export default function urlMix(art) {
const typeCallback = option.customType[typeName]
if (typeName && typeCallback) {
await sleep()
if (!await wait(getScope(art)) || getScope(art).closed)
return
art.loading.show = true
typeCallback.call(art, $video, newUrl, art)
}
@@ -36,7 +39,8 @@ export default function urlMix(art) {
}
}
else {
await sleep()
if (!await wait(getScope(art)) || getScope(art).closed)
return
art.loading.show = true
}
},
+3 -1
View File
@@ -1,3 +1,5 @@
import { getScope } from '../lifecycle/instance'
import { timeout } from '../lifecycle/resources'
import {
addClass,
append,
@@ -526,7 +528,7 @@ export default class Setting extends Component {
}
if (item.mounted) {
setTimeout(() => item.mounted.call(this.art, item.$item, item), 0)
timeout(getScope(this.art), () => item.mounted.call(this.art, item.$item, item), 0)
}
}
+3
View File
@@ -1,4 +1,6 @@
import { version } from '../package.json'
import { ownContainer } from './lifecycle/instance'
import captureTemplate from './lifecycle/template-rollback'
import { addClass, errorHandle, isMobile, query, replaceElement, supportsFlex } from './utils'
export default class Template {
@@ -25,6 +27,7 @@ export default class Template {
)
this.query = this.query.bind(this)
ownContainer(art, this.$container, captureTemplate(this.$container))
this.$container.dataset.artId = art.id
this.init()
}