@prefetchru/prefetch 1.0.9 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -5
- package/dist/prefetch.esm.min.js +2 -2
- package/dist/prefetch.min.js +2 -2
- package/package.json +3 -1
- package/prefetch.esm.js +638 -274
- package/prefetch.js +852 -477
- package/src/core.js +923 -0
- package/src/entry-esm.js +58 -0
- package/src/entry-iife.js +27 -0
package/src/core.js
ADDED
|
@@ -0,0 +1,923 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core prefetch logic - shared between IIFE and ESM builds.
|
|
3
|
+
* @param {Object} options
|
|
4
|
+
* @param {function(): string|null} options.getNonce - Function to get CSP nonce
|
|
5
|
+
* @param {boolean} options.isBrowser - Whether running in browser environment
|
|
6
|
+
* @returns {Object} Prefetch API
|
|
7
|
+
*/
|
|
8
|
+
export function createPrefetchCore(options) {
|
|
9
|
+
'use strict'
|
|
10
|
+
|
|
11
|
+
var getNonce = options && options.getNonce
|
|
12
|
+
var isBrowser = options ? options.isBrowser : true
|
|
13
|
+
|
|
14
|
+
// SSR/Non-browser guard
|
|
15
|
+
if (!isBrowser || typeof window === 'undefined' || typeof document === 'undefined') {
|
|
16
|
+
return {
|
|
17
|
+
__prefetchRu: true,
|
|
18
|
+
version: '__VERSION__',
|
|
19
|
+
preload: function () {},
|
|
20
|
+
destroy: function () {},
|
|
21
|
+
refresh: function () {}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Состояние
|
|
26
|
+
var preloaded = new Set()
|
|
27
|
+
var hoverTimers = new WeakMap()
|
|
28
|
+
var disabled = false
|
|
29
|
+
|
|
30
|
+
// v1.0.11: in-flight лимит (макс. параллельных запросов)
|
|
31
|
+
var inFlight = 0
|
|
32
|
+
var maxInFlight = 4
|
|
33
|
+
var queue = []
|
|
34
|
+
var maxQueue = 50 // v1.0.11: лимит очереди (защита от переполнения)
|
|
35
|
+
|
|
36
|
+
// v1.0.13: Set активных AbortController для корректного destroy()
|
|
37
|
+
var activeControllers = new Set()
|
|
38
|
+
|
|
39
|
+
// v1.0.13: буфер для Speculation Rules (группируем URL и вставляем одним JSON)
|
|
40
|
+
var specBuffer = { prefetch: [], prerender: [], crossOrigin: [] }
|
|
41
|
+
var specFlushTimer = 0
|
|
42
|
+
|
|
43
|
+
// v1.0.11: кэш ключа текущей страницы (избегаем new URL() в горячих местах)
|
|
44
|
+
var currentKey = ''
|
|
45
|
+
|
|
46
|
+
var lastTouchTime = 0
|
|
47
|
+
var touchTimer = 0
|
|
48
|
+
var touchCancel = null
|
|
49
|
+
|
|
50
|
+
var isMobile = false
|
|
51
|
+
var isIOS = false
|
|
52
|
+
var chromiumVer = null
|
|
53
|
+
var platform = null
|
|
54
|
+
var saveData = false
|
|
55
|
+
var connType = null
|
|
56
|
+
|
|
57
|
+
// CSP / поддержка
|
|
58
|
+
var scriptNonce = null
|
|
59
|
+
var supportsLinkPrefetch = false
|
|
60
|
+
|
|
61
|
+
// Настройки
|
|
62
|
+
var hoverDelay = 65
|
|
63
|
+
var touchDelay = 80
|
|
64
|
+
var maxPreloads = 50
|
|
65
|
+
var allowQuery = false
|
|
66
|
+
var allowExternal = false
|
|
67
|
+
var whitelist = false
|
|
68
|
+
|
|
69
|
+
var useSpecRules = false
|
|
70
|
+
var specMode = 'none' // 'none' | 'prefetch' | 'prerender'
|
|
71
|
+
var specRulesFallback = false // v1.0.11: fallback при SpecRules (по умолчанию отключён)
|
|
72
|
+
var prerenderAll = false
|
|
73
|
+
|
|
74
|
+
var mousedownMode = false
|
|
75
|
+
var viewportMode = false
|
|
76
|
+
var observeDom = false
|
|
77
|
+
|
|
78
|
+
// v1.0.11: regex вынесены в верхний scope (perf — не создавать на каждый вызов)
|
|
79
|
+
var DANGEROUS_PATH_RE = /(^|\/)(login|logout|auth|register|cart|basket|add|delete|remove)(\/|$|\.)/i
|
|
80
|
+
var FILE_EXT_RE = /\.(pdf|doc|docx|xls|xlsx|zip|rar|exe)($|\?)/i
|
|
81
|
+
|
|
82
|
+
// Инициализация
|
|
83
|
+
;(function init() {
|
|
84
|
+
// CSP nonce через переданную функцию
|
|
85
|
+
if (getNonce) {
|
|
86
|
+
try {
|
|
87
|
+
scriptNonce = getNonce()
|
|
88
|
+
} catch (e) {}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// rel=prefetch support
|
|
92
|
+
try {
|
|
93
|
+
var l = document.createElement('link')
|
|
94
|
+
if (l.relList && typeof l.relList.supports === 'function') {
|
|
95
|
+
supportsLinkPrefetch = l.relList.supports('prefetch')
|
|
96
|
+
}
|
|
97
|
+
} catch (e) {}
|
|
98
|
+
|
|
99
|
+
var ua = navigator.userAgent
|
|
100
|
+
var uaData = navigator.userAgentData // v1.0.12: UA-CH API (более надёжно в долгосрочной перспективе)
|
|
101
|
+
|
|
102
|
+
// Определяем устройство
|
|
103
|
+
// v1.0.12: используем userAgentData если доступен, иначе fallback на UA
|
|
104
|
+
if (uaData) {
|
|
105
|
+
// UA-CH API: https://developer.mozilla.org/en-US/docs/Web/API/NavigatorUAData
|
|
106
|
+
isIOS = false // UA-CH пока не поддерживается на iOS
|
|
107
|
+
var isAndroid = uaData.platform === 'Android'
|
|
108
|
+
isMobile = uaData.mobile || false
|
|
109
|
+
// Chromium версия через brands
|
|
110
|
+
var brands = uaData.brands || []
|
|
111
|
+
for (var i = 0; i < brands.length; i++) {
|
|
112
|
+
var b = brands[i]
|
|
113
|
+
if (b.brand === 'Chromium' || b.brand === 'Google Chrome') {
|
|
114
|
+
chromiumVer = parseInt(b.version, 10)
|
|
115
|
+
break
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
} else {
|
|
119
|
+
// Fallback на традиционный UA sniffing
|
|
120
|
+
isIOS =
|
|
121
|
+
/iPad|iPhone/.test(ua) ||
|
|
122
|
+
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)
|
|
123
|
+
var isAndroid = /Android/.test(ua)
|
|
124
|
+
isMobile = (isIOS || isAndroid) && Math.min(screen.width, screen.height) < 768
|
|
125
|
+
// Chromium версия из UA
|
|
126
|
+
var cm = ua.match(/Chrome\/(\d+)/)
|
|
127
|
+
if (cm) chromiumVer = parseInt(cm[1], 10)
|
|
128
|
+
}
|
|
129
|
+
if (isMobile) maxPreloads = 20
|
|
130
|
+
|
|
131
|
+
// Сеть
|
|
132
|
+
var conn = navigator.connection
|
|
133
|
+
if (conn) {
|
|
134
|
+
connType = conn.effectiveType
|
|
135
|
+
saveData = conn.saveData || false
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Ждём DOM
|
|
139
|
+
if (document.readyState === 'loading') {
|
|
140
|
+
document.addEventListener('DOMContentLoaded', setup)
|
|
141
|
+
} else {
|
|
142
|
+
setup()
|
|
143
|
+
}
|
|
144
|
+
})()
|
|
145
|
+
|
|
146
|
+
function setup() {
|
|
147
|
+
// v1.0.11: защита от вызова после destroy()
|
|
148
|
+
if (disabled) return
|
|
149
|
+
|
|
150
|
+
var body = document.body
|
|
151
|
+
if (!body) return
|
|
152
|
+
|
|
153
|
+
// v1.0.11: кэшируем ключ текущей страницы
|
|
154
|
+
currentKey = location.origin + location.pathname + location.search
|
|
155
|
+
|
|
156
|
+
platform = detectPlatform()
|
|
157
|
+
|
|
158
|
+
// Читаем конфигурацию
|
|
159
|
+
var ds = body.dataset
|
|
160
|
+
allowQuery = 'prefetchAllowQueryString' in ds || 'instantAllowQueryString' in ds
|
|
161
|
+
allowExternal = 'prefetchAllowExternalLinks' in ds || 'instantAllowExternalLinks' in ds
|
|
162
|
+
whitelist = 'prefetchWhitelist' in ds || 'instantWhitelist' in ds
|
|
163
|
+
|
|
164
|
+
// CSP nonce override через data-*
|
|
165
|
+
// <body data-prefetch-nonce="...">
|
|
166
|
+
if (ds.prefetchNonce) scriptNonce = ds.prefetchNonce
|
|
167
|
+
if (!scriptNonce && ds.instantNonce) scriptNonce = ds.instantNonce
|
|
168
|
+
|
|
169
|
+
// Speculation Rules — opt-in по наличию атрибута:
|
|
170
|
+
// <body data-prefetch-specrules> (prefetch)
|
|
171
|
+
// <body data-prefetch-specrules="prerender">
|
|
172
|
+
// <body data-prefetch-specrules="no">
|
|
173
|
+
var hasSr = 'prefetchSpecrules' in ds || 'instantSpecrules' in ds
|
|
174
|
+
if (
|
|
175
|
+
!isIOS &&
|
|
176
|
+
hasSr &&
|
|
177
|
+
HTMLScriptElement.supports &&
|
|
178
|
+
HTMLScriptElement.supports('speculationrules')
|
|
179
|
+
) {
|
|
180
|
+
var sr = ds.prefetchSpecrules || ds.instantSpecrules
|
|
181
|
+
if (sr === 'prerender') {
|
|
182
|
+
specMode = 'prerender'
|
|
183
|
+
useSpecRules = true
|
|
184
|
+
} else if (sr !== 'no') {
|
|
185
|
+
specMode = 'prefetch'
|
|
186
|
+
useSpecRules = true
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// v1.0.11: fallback при Speculation Rules (по умолчанию отключён для избежания двойного трафика)
|
|
191
|
+
// <body data-prefetch-specrules-fallback>
|
|
192
|
+
specRulesFallback = 'prefetchSpecrulesFallback' in ds || 'instantSpecrulesFallback' in ds
|
|
193
|
+
|
|
194
|
+
// Разрешить "глобальный" prerender без whitelist (не рекомендуется, но бывает нужно)
|
|
195
|
+
// <body data-prefetch-prerender-all>
|
|
196
|
+
prerenderAll = 'prefetchPrerenderAll' in ds || 'instantPrerenderAll' in ds
|
|
197
|
+
|
|
198
|
+
// Интенсивность
|
|
199
|
+
var intensity = ds.prefetchIntensity || ds.instantIntensity
|
|
200
|
+
if (intensity === 'mousedown') {
|
|
201
|
+
mousedownMode = true
|
|
202
|
+
} else if (intensity === 'viewport' || intensity === 'viewport-all') {
|
|
203
|
+
if (intensity === 'viewport-all' || (isMobile && isNetworkOk())) {
|
|
204
|
+
viewportMode = true
|
|
205
|
+
}
|
|
206
|
+
} else if (intensity) {
|
|
207
|
+
var d = parseInt(intensity, 10)
|
|
208
|
+
if (!isNaN(d) && d >= 0) hoverDelay = d
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// На мобильных делаем touch-предзагрузку менее агрессивной
|
|
212
|
+
if (isMobile) {
|
|
213
|
+
touchDelay = Math.max(60, Math.min(hoverDelay || 0, 150))
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// DOM observer для SPA
|
|
217
|
+
// v1.0.11: добавлен алиас instantObserveDom
|
|
218
|
+
observeDom = 'prefetchObserveDom' in ds || 'instantObserveDom' in ds
|
|
219
|
+
if (!observeDom && (platform === 'bitrix' || platform === 'tilda')) {
|
|
220
|
+
observeDom = true
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// v1.0.11: обновляем currentKey при навигации (pushState, popstate, hashchange)
|
|
224
|
+
window.addEventListener('popstate', updateCurrentKey)
|
|
225
|
+
window.addEventListener('hashchange', updateCurrentKey)
|
|
226
|
+
// v1.0.12: pageshow для bfcache restore (popstate не всегда срабатывает при возврате из bfcache)
|
|
227
|
+
window.addEventListener('pageshow', onPageShow)
|
|
228
|
+
|
|
229
|
+
// Tilda — увеличиваем задержку из-за popup-ов
|
|
230
|
+
if (platform === 'tilda' && hoverDelay < 100) hoverDelay = 100
|
|
231
|
+
|
|
232
|
+
// События
|
|
233
|
+
var opts = { capture: true, passive: true }
|
|
234
|
+
document.addEventListener('touchstart', onTouchStart, opts)
|
|
235
|
+
if (!mousedownMode) {
|
|
236
|
+
document.addEventListener('mouseover', onMouseOver, opts)
|
|
237
|
+
} else {
|
|
238
|
+
document.addEventListener('mousedown', onMouseDown, opts)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Viewport observer
|
|
242
|
+
// v1.0.10: feature-detection — если IntersectionObserver недоступен, отключаем viewport режим
|
|
243
|
+
if (viewportMode && typeof IntersectionObserver === 'undefined') viewportMode = false
|
|
244
|
+
|
|
245
|
+
if (viewportMode) {
|
|
246
|
+
var rIC = window.requestIdleCallback || function (cb) { setTimeout(cb, 1) }
|
|
247
|
+
rIC(startViewportObserver, { timeout: 1500 })
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// v1.0.9: MutationObserver нужен только для viewport режима (отслеживать новые ссылки)
|
|
251
|
+
// v1.0.10: feature-detection — если MutationObserver недоступен, не запускаем
|
|
252
|
+
if (observeDom && viewportMode && typeof MutationObserver !== 'undefined') startMutationObserver()
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function detectPlatform() {
|
|
256
|
+
if (typeof window.BX !== 'undefined') return 'bitrix'
|
|
257
|
+
if (typeof window.B24 !== 'undefined' || typeof window.BX24 !== 'undefined') return 'bitrix24'
|
|
258
|
+
if (document.querySelector('.t-records') || typeof window.Tilda !== 'undefined') return 'tilda'
|
|
259
|
+
return null
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function isNetworkOk() {
|
|
263
|
+
if (saveData) return false
|
|
264
|
+
if (connType === 'slow-2g' || connType === '2g' || connType === '3g') return false
|
|
265
|
+
return true
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// v1.0.11: обновление currentKey при навигации (SPA-гибриды, pushState)
|
|
269
|
+
function updateCurrentKey() {
|
|
270
|
+
currentKey = location.origin + location.pathname + location.search
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// v1.0.12: pageshow для bfcache restore
|
|
274
|
+
function onPageShow(e) {
|
|
275
|
+
// persisted === true означает восстановление из bfcache
|
|
276
|
+
if (e && e.persisted) {
|
|
277
|
+
updateCurrentKey()
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function getAnchorFromEventTarget(t) {
|
|
282
|
+
if (!t) return null
|
|
283
|
+
if (t.nodeType && t.nodeType !== 1) t = t.parentElement
|
|
284
|
+
if (!t || typeof t.closest !== 'function') return null
|
|
285
|
+
return t.closest('a')
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function onTouchStart(e) {
|
|
289
|
+
// v1.0.11: защита от синтетических событий и disabled режим
|
|
290
|
+
if (disabled) return
|
|
291
|
+
if (e && e.isTrusted === false) return
|
|
292
|
+
|
|
293
|
+
// v1.0.9: используем Date.now() для единой шкалы времени
|
|
294
|
+
lastTouchTime = Date.now()
|
|
295
|
+
|
|
296
|
+
var a = getAnchorFromEventTarget(e.target)
|
|
297
|
+
if (!canPreload(a)) return
|
|
298
|
+
|
|
299
|
+
// задержка + отмена на scroll/touchmove
|
|
300
|
+
if (touchTimer) {
|
|
301
|
+
clearTimeout(touchTimer)
|
|
302
|
+
touchTimer = 0
|
|
303
|
+
}
|
|
304
|
+
if (touchCancel) {
|
|
305
|
+
document.removeEventListener('touchmove', touchCancel, true)
|
|
306
|
+
document.removeEventListener('scroll', touchCancel, true)
|
|
307
|
+
touchCancel = null
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
var cancelled = false
|
|
311
|
+
touchCancel = function () {
|
|
312
|
+
cancelled = true
|
|
313
|
+
if (touchTimer) {
|
|
314
|
+
clearTimeout(touchTimer)
|
|
315
|
+
touchTimer = 0
|
|
316
|
+
}
|
|
317
|
+
document.removeEventListener('touchmove', touchCancel, true)
|
|
318
|
+
document.removeEventListener('scroll', touchCancel, true)
|
|
319
|
+
touchCancel = null
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
document.addEventListener('touchmove', touchCancel, { capture: true, passive: true, once: true })
|
|
323
|
+
document.addEventListener('scroll', touchCancel, { capture: true, passive: true, once: true })
|
|
324
|
+
|
|
325
|
+
touchTimer = setTimeout(function () {
|
|
326
|
+
if (touchCancel) {
|
|
327
|
+
document.removeEventListener('touchmove', touchCancel, true)
|
|
328
|
+
document.removeEventListener('scroll', touchCancel, true)
|
|
329
|
+
touchCancel = null
|
|
330
|
+
}
|
|
331
|
+
touchTimer = 0
|
|
332
|
+
if (!cancelled) preload(a.href, a)
|
|
333
|
+
}, touchDelay)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function onMouseOver(e) {
|
|
337
|
+
// v1.0.11: защита от синтетических событий и disabled режим
|
|
338
|
+
if (disabled) return
|
|
339
|
+
if (e && e.isTrusted === false) return
|
|
340
|
+
|
|
341
|
+
// v1.0.9: единая шкала времени Date.now()
|
|
342
|
+
if (lastTouchTime && Date.now() - lastTouchTime < 2500) return
|
|
343
|
+
|
|
344
|
+
var a = getAnchorFromEventTarget(e.target)
|
|
345
|
+
if (!a) return
|
|
346
|
+
|
|
347
|
+
// v1.0.11: проверяем таймер ДО canPreload (perf — mouseover очень шумный)
|
|
348
|
+
if (hoverTimers.has(a)) return
|
|
349
|
+
|
|
350
|
+
if (!canPreload(a)) return
|
|
351
|
+
|
|
352
|
+
// mouseleave не срабатывает при перемещении внутри ссылки (в отличие от mouseout)
|
|
353
|
+
a.addEventListener('mouseleave', onMouseLeave, { passive: true, once: true })
|
|
354
|
+
|
|
355
|
+
var t = setTimeout(function () {
|
|
356
|
+
preload(a.href, a)
|
|
357
|
+
hoverTimers.delete(a)
|
|
358
|
+
}, hoverDelay)
|
|
359
|
+
hoverTimers.set(a, t)
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function onMouseLeave(e) {
|
|
363
|
+
var a = e.currentTarget
|
|
364
|
+
if (!a) return
|
|
365
|
+
|
|
366
|
+
var t = hoverTimers.get(a)
|
|
367
|
+
if (t) {
|
|
368
|
+
clearTimeout(t)
|
|
369
|
+
hoverTimers.delete(a)
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function onMouseDown(e) {
|
|
374
|
+
// v1.0.11: защита от синтетических событий и disabled режим
|
|
375
|
+
if (disabled) return
|
|
376
|
+
if (e && e.isTrusted === false) return
|
|
377
|
+
|
|
378
|
+
// v1.0.12: button===1 (middle click) тоже открывает новую вкладку — префетч бессмысленен
|
|
379
|
+
if (typeof e.button === 'number' && (e.button === 1 || e.button === 2)) return
|
|
380
|
+
// v1.0.10: при модификаторах (Ctrl/Meta/Shift/Alt) открывается новая вкладка — префетч бессмысленен
|
|
381
|
+
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
|
|
382
|
+
// v1.0.9: единая шкала времени Date.now()
|
|
383
|
+
if (lastTouchTime && Date.now() - lastTouchTime < 2500) return
|
|
384
|
+
|
|
385
|
+
var a = getAnchorFromEventTarget(e.target)
|
|
386
|
+
if (canPreload(a)) preload(a.href, a)
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function canPreload(a) {
|
|
390
|
+
if (!a) return false
|
|
391
|
+
|
|
392
|
+
// v1.0.7: исключаем <a href=""> и <a> без href (часто используются как кнопки)
|
|
393
|
+
var hrefAttr = a.getAttribute('href')
|
|
394
|
+
if (hrefAttr === null || hrefAttr.trim() === '') return false
|
|
395
|
+
|
|
396
|
+
if (!a.href) return false
|
|
397
|
+
|
|
398
|
+
// Не навигация в текущей вкладке
|
|
399
|
+
if (a.target && a.target !== '_self') return false
|
|
400
|
+
if (a.hasAttribute('download')) return false
|
|
401
|
+
|
|
402
|
+
// Явный запрет
|
|
403
|
+
if ('noPrefetch' in a.dataset || 'prefetchNo' in a.dataset) return false
|
|
404
|
+
|
|
405
|
+
// Белый список
|
|
406
|
+
if (whitelist && !('prefetch' in a.dataset) && !('instant' in a.dataset)) return false
|
|
407
|
+
|
|
408
|
+
// Протокол
|
|
409
|
+
if (a.protocol !== 'http:' && a.protocol !== 'https:') return false
|
|
410
|
+
if (a.protocol === 'http:' && location.protocol === 'https:') return false
|
|
411
|
+
|
|
412
|
+
// Внешние ссылки
|
|
413
|
+
if (a.origin !== location.origin) {
|
|
414
|
+
if (!allowExternal && !('prefetch' in a.dataset) && !('instant' in a.dataset)) return false
|
|
415
|
+
if (!chromiumVer) return false
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Query string
|
|
419
|
+
if (a.search && !allowQuery && !('prefetch' in a.dataset) && !('instant' in a.dataset)) return false
|
|
420
|
+
|
|
421
|
+
// Якорь на той же странице
|
|
422
|
+
if (a.hash && a.pathname + a.search === location.pathname + location.search) return false
|
|
423
|
+
|
|
424
|
+
// v1.0.11: используем свойства <a> напрямую вместо new URL() (perf)
|
|
425
|
+
var key = a.origin + a.pathname + a.search
|
|
426
|
+
if (key === currentKey) return false
|
|
427
|
+
|
|
428
|
+
// Уже загружено
|
|
429
|
+
if (preloaded.has(key)) return false
|
|
430
|
+
|
|
431
|
+
if (!checkPlatform(a)) return false
|
|
432
|
+
if (!checkAnalytics(a)) return false
|
|
433
|
+
|
|
434
|
+
return true
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function checkPlatform(a) {
|
|
438
|
+
var href = a.href
|
|
439
|
+
|
|
440
|
+
// v1.0.11: используем свойства <a> напрямую вместо new URL() (perf)
|
|
441
|
+
var pathname = a.pathname || ''
|
|
442
|
+
var hash = a.hash || ''
|
|
443
|
+
|
|
444
|
+
if (platform === 'bitrix' || platform === 'bitrix24') {
|
|
445
|
+
if (href.indexOf('/bitrix/') !== -1 || href.indexOf('sessid=') !== -1) return false
|
|
446
|
+
if (a.classList.contains('bx-ajax')) return false
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if (platform === 'tilda') {
|
|
450
|
+
// Можно проверять и по href, но hash надёжнее/дешевле
|
|
451
|
+
if (hash.indexOf('#popup:') !== -1 || hash.indexOf('#rec') !== -1) return false
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// v1.0.9: все опасные пути проверяем по сегментам pathname, не по подстроке href
|
|
455
|
+
// v1.0.11: regex вынесены в верхний scope (perf)
|
|
456
|
+
if (DANGEROUS_PATH_RE.test(pathname)) return false
|
|
457
|
+
// v1.0.11: проверяем расширение по pathname (href может содержать #hash)
|
|
458
|
+
if (FILE_EXT_RE.test(pathname)) return false
|
|
459
|
+
|
|
460
|
+
return true
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function checkAnalytics(a) {
|
|
464
|
+
var cls = a.className || ''
|
|
465
|
+
|
|
466
|
+
// v1.0.11: используем свойства <a> напрямую вместо new URL() (perf)
|
|
467
|
+
var host = a.hostname || ''
|
|
468
|
+
|
|
469
|
+
if (cls.indexOf('ym-') !== -1) return false
|
|
470
|
+
if (host === 'mc.yandex.ru' || host === 'metrika.yandex.ru') return false
|
|
471
|
+
|
|
472
|
+
if (cls.indexOf('ga-') !== -1 || cls.indexOf('gtm-') !== -1) return false
|
|
473
|
+
if (host === 'google-analytics.com' || host.endsWith('.google-analytics.com')) return false
|
|
474
|
+
if (host === 'googletagmanager.com' || host.endsWith('.googletagmanager.com')) return false
|
|
475
|
+
|
|
476
|
+
if (cls.indexOf('piwik') !== -1 || cls.indexOf('matomo') !== -1) return false
|
|
477
|
+
if (
|
|
478
|
+
host === 'matomo.org' ||
|
|
479
|
+
host.endsWith('.matomo.org') ||
|
|
480
|
+
host === 'piwik.org' ||
|
|
481
|
+
host.endsWith('.piwik.org')
|
|
482
|
+
) return false
|
|
483
|
+
|
|
484
|
+
return true
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// v1.0.12: объединённая функция — парсим URL один раз вместо двух
|
|
488
|
+
// Возвращает { requestUrl, key } для preload()
|
|
489
|
+
function parseUrl(url) {
|
|
490
|
+
try {
|
|
491
|
+
var u = new URL(url, location.href)
|
|
492
|
+
var key = u.origin + u.pathname + u.search
|
|
493
|
+
u.hash = ''
|
|
494
|
+
return { requestUrl: u.href, key: key }
|
|
495
|
+
} catch (e) {
|
|
496
|
+
return { requestUrl: url, key: url }
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function resolveSpecMode(a) {
|
|
501
|
+
if (!useSpecRules || specMode === 'none') return 'none'
|
|
502
|
+
if (specMode !== 'prerender') return specMode
|
|
503
|
+
|
|
504
|
+
// specMode === 'prerender'
|
|
505
|
+
if (prerenderAll) return 'prerender'
|
|
506
|
+
if (whitelist) return 'prerender' // ссылки и так явно размечены
|
|
507
|
+
|
|
508
|
+
// иначе prerender только по ссылке:
|
|
509
|
+
if (a && a.dataset && (('prefetchPrerender' in a.dataset) || ('instantPrerender' in a.dataset))) {
|
|
510
|
+
return 'prerender'
|
|
511
|
+
}
|
|
512
|
+
return 'prefetch'
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function preload(url, a) {
|
|
516
|
+
if (disabled) return
|
|
517
|
+
if (!isNetworkOk()) return
|
|
518
|
+
|
|
519
|
+
// v1.0.12: парсим URL один раз вместо двух
|
|
520
|
+
var parsed = parseUrl(url)
|
|
521
|
+
var requestUrl = parsed.requestUrl
|
|
522
|
+
var key = parsed.key
|
|
523
|
+
|
|
524
|
+
if (preloaded.has(key)) return
|
|
525
|
+
if (preloaded.size >= maxPreloads) {
|
|
526
|
+
preloaded.delete(preloaded.values().next().value)
|
|
527
|
+
}
|
|
528
|
+
preloaded.add(key)
|
|
529
|
+
|
|
530
|
+
var mode = resolveSpecMode(a)
|
|
531
|
+
|
|
532
|
+
// v1.0.11: in-flight лимит — ставим в очередь если превышен
|
|
533
|
+
if (inFlight >= maxInFlight) {
|
|
534
|
+
// v1.0.11: лимит очереди — отбрасываем новые при переполнении
|
|
535
|
+
// v1.0.11: при drop удаляем ключ (иначе URL "навсегда" считается прогретым)
|
|
536
|
+
if (queue.length >= maxQueue) { preloaded.delete(key); return }
|
|
537
|
+
queue.push({ url: requestUrl, key: key, mode: mode })
|
|
538
|
+
return
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
doPreload(requestUrl, key, mode)
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function doPreload(requestUrl, key, mode) {
|
|
545
|
+
if (mode !== 'none') {
|
|
546
|
+
// v1.0.11: try/catch для preloadSpec — при строгом CSP/Trusted Types может выбросить исключение
|
|
547
|
+
var specOk = false
|
|
548
|
+
try {
|
|
549
|
+
preloadSpec(requestUrl, mode)
|
|
550
|
+
specOk = true
|
|
551
|
+
} catch (e) {
|
|
552
|
+
// Ошибка в Speculation Rules — fallback обязателен
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// v1.0.11: fallback только если явно включён ИЛИ если SpecRules не удался
|
|
556
|
+
// По умолчанию fallback отключён для избежания двойного трафика
|
|
557
|
+
if (specRulesFallback || !specOk) {
|
|
558
|
+
if (isIOS || !supportsLinkPrefetch) preloadFetch(requestUrl, key)
|
|
559
|
+
else preloadLink(requestUrl, key)
|
|
560
|
+
}
|
|
561
|
+
return
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
if (isIOS || !supportsLinkPrefetch) preloadFetch(requestUrl, key)
|
|
565
|
+
else preloadLink(requestUrl, key)
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function processQueue() {
|
|
569
|
+
while (queue.length > 0 && inFlight < maxInFlight) {
|
|
570
|
+
var item = queue.shift()
|
|
571
|
+
doPreload(item.url, item.key, item.mode)
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function preloadSpec(url, mode) {
|
|
576
|
+
// v1.0.13: для cross-origin проверяем и модифицируем правила
|
|
577
|
+
var isCrossOrigin = false
|
|
578
|
+
try {
|
|
579
|
+
var u = new URL(url, location.href)
|
|
580
|
+
isCrossOrigin = u.origin !== location.origin
|
|
581
|
+
} catch (e) {}
|
|
582
|
+
|
|
583
|
+
// v1.0.13: для cross-origin никогда не делаем prerender (только prefetch)
|
|
584
|
+
if (isCrossOrigin && mode === 'prerender') {
|
|
585
|
+
mode = 'prefetch'
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// v1.0.13: буферизуем URL — вставляем одним JSON за idle tick
|
|
589
|
+
if (isCrossOrigin) {
|
|
590
|
+
specBuffer.crossOrigin.push(url)
|
|
591
|
+
} else if (mode === 'prerender') {
|
|
592
|
+
specBuffer.prerender.push(url)
|
|
593
|
+
} else {
|
|
594
|
+
specBuffer.prefetch.push(url)
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// Планируем flush если ещё не запланирован
|
|
598
|
+
if (!specFlushTimer) {
|
|
599
|
+
var rIC = window.requestIdleCallback || function (cb) { setTimeout(cb, 1) }
|
|
600
|
+
specFlushTimer = rIC(flushSpecBuffer, { timeout: 50 })
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// v1.0.13: вставляем все накопленные URL одним JSON
|
|
605
|
+
function flushSpecBuffer() {
|
|
606
|
+
specFlushTimer = 0
|
|
607
|
+
|
|
608
|
+
// v1.0.13: проверяем disabled (flush может быть вызван после destroy())
|
|
609
|
+
if (disabled) return
|
|
610
|
+
|
|
611
|
+
var head = document.head
|
|
612
|
+
if (!head) return
|
|
613
|
+
|
|
614
|
+
var rules = {}
|
|
615
|
+
|
|
616
|
+
// Same-origin prefetch
|
|
617
|
+
if (specBuffer.prefetch.length > 0) {
|
|
618
|
+
rules.prefetch = rules.prefetch || []
|
|
619
|
+
rules.prefetch.push({ source: 'list', urls: specBuffer.prefetch.slice() })
|
|
620
|
+
specBuffer.prefetch.length = 0
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// Same-origin prerender
|
|
624
|
+
if (specBuffer.prerender.length > 0) {
|
|
625
|
+
rules.prerender = rules.prerender || []
|
|
626
|
+
rules.prerender.push({ source: 'list', urls: specBuffer.prerender.slice() })
|
|
627
|
+
specBuffer.prerender.length = 0
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// Cross-origin (только prefetch, с privacy requirements)
|
|
631
|
+
if (specBuffer.crossOrigin.length > 0) {
|
|
632
|
+
rules.prefetch = rules.prefetch || []
|
|
633
|
+
rules.prefetch.push({
|
|
634
|
+
source: 'list',
|
|
635
|
+
urls: specBuffer.crossOrigin.slice(),
|
|
636
|
+
referrer_policy: 'no-referrer',
|
|
637
|
+
requires: ['anonymous-client-ip-when-cross-origin']
|
|
638
|
+
})
|
|
639
|
+
specBuffer.crossOrigin.length = 0
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// Если нет правил — выходим
|
|
643
|
+
if (!rules.prefetch && !rules.prerender) return
|
|
644
|
+
|
|
645
|
+
var s = document.createElement('script')
|
|
646
|
+
s.type = 'speculationrules'
|
|
647
|
+
if (scriptNonce) s.nonce = scriptNonce
|
|
648
|
+
s.textContent = JSON.stringify(rules)
|
|
649
|
+
head.appendChild(s)
|
|
650
|
+
// v1.0.11: удаляем после вставки — браузер применяет правила на appendChild
|
|
651
|
+
head.removeChild(s)
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function preloadLink(url, key) {
|
|
655
|
+
var head = document.head
|
|
656
|
+
// v1.0.10: если head недоступен, откатываем ключ
|
|
657
|
+
if (!head) { preloaded.delete(key); return }
|
|
658
|
+
|
|
659
|
+
inFlight++
|
|
660
|
+
|
|
661
|
+
var l = document.createElement('link')
|
|
662
|
+
l.rel = 'prefetch'
|
|
663
|
+
l.href = url
|
|
664
|
+
l.as = 'document'
|
|
665
|
+
try { l.fetchPriority = 'low' } catch (e) {}
|
|
666
|
+
|
|
667
|
+
// v1.0.11: для cross-origin: referrerPolicy + crossOrigin
|
|
668
|
+
try {
|
|
669
|
+
var u = new URL(url, location.href)
|
|
670
|
+
if (u.origin !== location.origin) {
|
|
671
|
+
l.referrerPolicy = 'no-referrer'
|
|
672
|
+
l.crossOrigin = 'anonymous' // не отправлять cookies на внешние домены
|
|
673
|
+
}
|
|
674
|
+
} catch (e) {}
|
|
675
|
+
|
|
676
|
+
// v1.0.13: safety timeout — предохранитель если onload/onerror не сработают
|
|
677
|
+
// (экзотические браузеры, сетевые ошибки без событий)
|
|
678
|
+
var safetyTimer = setTimeout(function () {
|
|
679
|
+
safetyTimer = 0
|
|
680
|
+
// v1.0.13: при safety timeout считаем попытку неуспешной — удаляем key
|
|
681
|
+
preloaded.delete(key)
|
|
682
|
+
cleanup()
|
|
683
|
+
}, 30000)
|
|
684
|
+
|
|
685
|
+
function cleanup() {
|
|
686
|
+
if (safetyTimer) { clearTimeout(safetyTimer); safetyTimer = 0 }
|
|
687
|
+
l.onload = l.onerror = null
|
|
688
|
+
if (l.parentNode) l.parentNode.removeChild(l)
|
|
689
|
+
inFlight--
|
|
690
|
+
processQueue()
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
l.onload = cleanup
|
|
694
|
+
l.onerror = function () { preloaded.delete(key); cleanup() }
|
|
695
|
+
head.appendChild(l)
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function preloadFetch(url, key) {
|
|
699
|
+
// v1.0.10: если fetch недоступен, откатываем ключ
|
|
700
|
+
if (typeof fetch !== 'function') { preloaded.delete(key); return }
|
|
701
|
+
|
|
702
|
+
inFlight++
|
|
703
|
+
|
|
704
|
+
// v1.0.11: settled флаг — защита от двойного вызова done() при abort+catch
|
|
705
|
+
var settled = false
|
|
706
|
+
var ctrl = null
|
|
707
|
+
var tid = 0
|
|
708
|
+
|
|
709
|
+
function done(success) {
|
|
710
|
+
if (settled) return
|
|
711
|
+
settled = true
|
|
712
|
+
|
|
713
|
+
if (tid) clearTimeout(tid)
|
|
714
|
+
// v1.0.13: удаляем контроллер из активных
|
|
715
|
+
if (ctrl) activeControllers.delete(ctrl)
|
|
716
|
+
if (!success) preloaded.delete(key)
|
|
717
|
+
inFlight--
|
|
718
|
+
processQueue()
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
if (typeof AbortController !== 'undefined') {
|
|
722
|
+
ctrl = new AbortController()
|
|
723
|
+
// v1.0.13: добавляем в Set для возможности abort при destroy()
|
|
724
|
+
activeControllers.add(ctrl)
|
|
725
|
+
tid = setTimeout(function () {
|
|
726
|
+
try { ctrl.abort() } catch (e) {}
|
|
727
|
+
done(false)
|
|
728
|
+
}, 5000)
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// v1.0.13: определяем cross-origin для корректных настроек fetch
|
|
732
|
+
var isCrossOrigin = false
|
|
733
|
+
try {
|
|
734
|
+
isCrossOrigin = new URL(url, location.href).origin !== location.origin
|
|
735
|
+
} catch (e) {}
|
|
736
|
+
|
|
737
|
+
var opts = {
|
|
738
|
+
method: 'GET',
|
|
739
|
+
cache: 'force-cache',
|
|
740
|
+
// v1.0.13: для cross-origin: omit credentials, no-referrer (избегаем CORS preflight и утечки referrer)
|
|
741
|
+
credentials: isCrossOrigin ? 'omit' : 'same-origin',
|
|
742
|
+
// v1.0.11: Accept header для корректного content negotiation
|
|
743
|
+
headers: {
|
|
744
|
+
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// v1.0.13: Purpose header только для same-origin (избегаем CORS preflight на cross-origin)
|
|
749
|
+
if (!isCrossOrigin) {
|
|
750
|
+
opts.headers.Purpose = 'prefetch'
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// v1.0.13: referrerPolicy для cross-origin (приватность)
|
|
754
|
+
if (isCrossOrigin) {
|
|
755
|
+
opts.referrerPolicy = 'no-referrer'
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
if (ctrl) opts.signal = ctrl.signal
|
|
759
|
+
|
|
760
|
+
try {
|
|
761
|
+
fetch(url, opts)
|
|
762
|
+
.then(function (r) {
|
|
763
|
+
// v1.0.12: 304 Not Modified тоже считаем успехом (кэш прогрет)
|
|
764
|
+
done(r && (r.ok || r.status === 304))
|
|
765
|
+
})
|
|
766
|
+
.catch(function () {
|
|
767
|
+
done(false)
|
|
768
|
+
})
|
|
769
|
+
} catch (e) {
|
|
770
|
+
done(false)
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// Viewport Observer
|
|
775
|
+
var vpObserver = null
|
|
776
|
+
|
|
777
|
+
function startViewportObserver() {
|
|
778
|
+
// v1.0.11: защита от вызова после destroy()
|
|
779
|
+
if (disabled) return
|
|
780
|
+
if (vpObserver) return
|
|
781
|
+
vpObserver = new IntersectionObserver(
|
|
782
|
+
function (entries) {
|
|
783
|
+
entries.forEach(function (entry) {
|
|
784
|
+
if (entry.isIntersecting) {
|
|
785
|
+
vpObserver.unobserve(entry.target)
|
|
786
|
+
if (canPreload(entry.target)) preload(entry.target.href, entry.target)
|
|
787
|
+
}
|
|
788
|
+
})
|
|
789
|
+
},
|
|
790
|
+
{ rootMargin: isMobile ? '100px' : '200px' }
|
|
791
|
+
)
|
|
792
|
+
observeLinks()
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
function observeLinks() {
|
|
796
|
+
if (!vpObserver) return
|
|
797
|
+
document.querySelectorAll('a').forEach(function (a) {
|
|
798
|
+
if (canPreload(a)) vpObserver.observe(a)
|
|
799
|
+
})
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// Mutation Observer
|
|
803
|
+
var mutObserver = null
|
|
804
|
+
var mutTimer = null
|
|
805
|
+
|
|
806
|
+
function startMutationObserver() {
|
|
807
|
+
// v1.0.11: защита от вызова после destroy()
|
|
808
|
+
if (disabled) return
|
|
809
|
+
if (mutObserver) return
|
|
810
|
+
mutObserver = new MutationObserver(function (muts) {
|
|
811
|
+
// v1.0.13: оптимизация — обычный цикл вместо Array.from + querySelector вместо querySelectorAll
|
|
812
|
+
var hasLinks = false
|
|
813
|
+
outer: for (var i = 0; i < muts.length; i++) {
|
|
814
|
+
var nodes = muts[i].addedNodes
|
|
815
|
+
for (var j = 0; j < nodes.length; j++) {
|
|
816
|
+
var n = nodes[j]
|
|
817
|
+
if (n.nodeType === 1) {
|
|
818
|
+
if (n.tagName === 'A' || (n.querySelector && n.querySelector('a'))) {
|
|
819
|
+
hasLinks = true
|
|
820
|
+
break outer
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
if (hasLinks && vpObserver) {
|
|
826
|
+
clearTimeout(mutTimer)
|
|
827
|
+
mutTimer = setTimeout(observeLinks, 100)
|
|
828
|
+
}
|
|
829
|
+
})
|
|
830
|
+
mutObserver.observe(document.body, { childList: true, subtree: true })
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// v1.0.11: валидация URL для публичного API (усилена)
|
|
834
|
+
function isValidPrefetchUrl(url) {
|
|
835
|
+
if (!url || typeof url !== 'string') return false
|
|
836
|
+
url = url.trim() // v1.0.11: trim для защиты от ' javascript:...'
|
|
837
|
+
if (!url) return false
|
|
838
|
+
// v1.0.11: блокируем protocol-relative URLs (//evil.com)
|
|
839
|
+
if (/^\/\//.test(url)) return false
|
|
840
|
+
// Блокируем опасные протоколы
|
|
841
|
+
if (/^(javascript|data|vbscript|file):/i.test(url)) return false
|
|
842
|
+
// Разрешаем только http(s) или относительные URL
|
|
843
|
+
if (!/^https?:\/\//i.test(url) && !/^\//.test(url) && !/^\./.test(url)) {
|
|
844
|
+
// Проверяем, что это не протокол
|
|
845
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(url)) return false
|
|
846
|
+
}
|
|
847
|
+
return true
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// v1.0.11: destroy() — отключает библиотеку, снимает обработчики
|
|
851
|
+
function destroy() {
|
|
852
|
+
disabled = true
|
|
853
|
+
queue.length = 0
|
|
854
|
+
|
|
855
|
+
// v1.0.13: очищаем touch-таймер
|
|
856
|
+
if (touchTimer) {
|
|
857
|
+
clearTimeout(touchTimer)
|
|
858
|
+
touchTimer = 0
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// v1.0.13: удаляем touchCancel listener если есть
|
|
862
|
+
if (touchCancel) {
|
|
863
|
+
document.removeEventListener('touchmove', touchCancel, true)
|
|
864
|
+
document.removeEventListener('scroll', touchCancel, true)
|
|
865
|
+
touchCancel = null
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// v1.0.13: прерываем все активные fetch-запросы
|
|
869
|
+
activeControllers.forEach(function (ctrl) {
|
|
870
|
+
try { ctrl.abort() } catch (e) {}
|
|
871
|
+
})
|
|
872
|
+
activeControllers.clear()
|
|
873
|
+
|
|
874
|
+
// v1.0.13: отменяем отложенный flush Speculation Rules
|
|
875
|
+
if (specFlushTimer) {
|
|
876
|
+
var cancelIC = window.cancelIdleCallback || clearTimeout
|
|
877
|
+
cancelIC(specFlushTimer)
|
|
878
|
+
specFlushTimer = 0
|
|
879
|
+
}
|
|
880
|
+
// Очищаем буфер
|
|
881
|
+
specBuffer.prefetch.length = specBuffer.prerender.length = specBuffer.crossOrigin.length = 0
|
|
882
|
+
|
|
883
|
+
var opts = { capture: true, passive: true }
|
|
884
|
+
document.removeEventListener('touchstart', onTouchStart, opts)
|
|
885
|
+
document.removeEventListener('mouseover', onMouseOver, opts)
|
|
886
|
+
document.removeEventListener('mousedown', onMouseDown, opts)
|
|
887
|
+
|
|
888
|
+
// v1.0.11: снимаем слушатели навигации
|
|
889
|
+
window.removeEventListener('popstate', updateCurrentKey)
|
|
890
|
+
window.removeEventListener('hashchange', updateCurrentKey)
|
|
891
|
+
// v1.0.12: снимаем pageshow listener
|
|
892
|
+
window.removeEventListener('pageshow', onPageShow)
|
|
893
|
+
|
|
894
|
+
if (vpObserver) {
|
|
895
|
+
vpObserver.disconnect()
|
|
896
|
+
vpObserver = null
|
|
897
|
+
}
|
|
898
|
+
if (mutObserver) {
|
|
899
|
+
mutObserver.disconnect()
|
|
900
|
+
mutObserver = null
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// Публичный API
|
|
905
|
+
var api = {
|
|
906
|
+
__prefetchRu: true,
|
|
907
|
+
version: '__VERSION__',
|
|
908
|
+
preload: function (url) {
|
|
909
|
+
// v1.0.11: валидация URL + прогон через canPreload() (консистентность с авто-режимом)
|
|
910
|
+
if (!isValidPrefetchUrl(url)) return
|
|
911
|
+
// v1.0.11: создаём временный <a> для проверки через canPreload()
|
|
912
|
+
var a = document.createElement('a')
|
|
913
|
+
a.setAttribute('href', url.trim())
|
|
914
|
+
if (!canPreload(a)) return
|
|
915
|
+
preload(a.href, a)
|
|
916
|
+
},
|
|
917
|
+
destroy: destroy,
|
|
918
|
+
// v1.0.11: публичный метод для ручного обновления currentKey (SPA)
|
|
919
|
+
refresh: updateCurrentKey
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
return api
|
|
923
|
+
}
|