├── README.md └── htmlimports.js /README.md: -------------------------------------------------------------------------------- 1 | # HTML imports polyfill 2 | This is a quick and dirty polyfill for HTML imports, used by [Construct 3](https://www.construct.net). 3 | 4 | For background, see the blog post [HTML Imports are the best web component](https://www.scirra.com/blog/ashley/34/html-imports-are-the-best-web-component). 5 | 6 | [Can I Use... HTML Imports](http://caniuse.com/#feat=imports) stats. 7 | 8 | ## Chrome deprecating style application from imports 9 | 10 | As of early 2018, Google are planning to remove style application from HTML imports. This is a backwards-incompatible change and will break any existing uses of styles in imports. The polyfill supports styles in imports, so to avoid breaking in Chrome, the native path has been deleted and the polyfill path is now used in all browsers. 11 | 12 | ## Usage 13 | 14 | Simply include htmlimports.js with a normal script tag. 15 | 16 | `` 17 | 18 | The script adds three global functions. Note the polyfill makes no effort to read existing `` tags; you must use the `addImport` method instead. 19 | 20 | `addImport(url, async, progressObject)` 21 | 22 | Load the HTML import at `url`. The `async` flag is no longer used (it only applied to the now-deleted native path). `progressObject` has `loaded` and `total` properties written to it which can be checked periodically to determine loading progress. The function returns a promise that resolves when the import has finished loading. 23 | 24 | `getImportDocument()` 25 | 26 | Returns the current import document, for script included from an import. 27 | 28 | ## How it works 29 | 30 | The polyfill's `addImport()` function does the following: 31 | 32 | 1. The import is fetched via XMLHttpRequest as a document. 33 | 2. Any stylesheets and scripts in the returned document are moved to the main document, so styles are applied and scripts are run. 34 | 3. Any further imports in the document are fetched and the process continues recursively. 35 | 4. `getImportDocument()` at the top level of any script included via an import is mapped to the document that included that script, allowing the script to access DOM content in its import. 36 | 37 | Note that scripts and stylesheets are expected to be at the top level - they won't be loaded if they are nested in other tags. 38 | 39 | The polyfill tries to load as much in parallel as possible, but only the browser has the necessary powers to load with optimal performance. In particular, the polyfill cannot even start fetching a script that comes after an import. 40 | 41 | ## Compatibility 42 | 43 | The polyfill should work in any modern browser with ES6 support (the polyfill uses ES6 features). 44 | 45 | The polyfill is robust enough to be a complete substitute for HTML imports in [Construct 3](https://www.construct.net), a complete game development IDE in a PWA. 46 | -------------------------------------------------------------------------------- /htmlimports.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | { 4 | // Map a script URL to its import document for GetImportDocument() 5 | const scriptUrlToImportDoc = new Map(); 6 | 7 | function GetPathFromURL(url) 8 | { 9 | if (!url.length) 10 | return url; // empty string 11 | 12 | const lastCh = url.charAt(url.length - 1); 13 | if (lastCh === "/" || lastCh === "\\") 14 | return url; // already a path terminated by slash 15 | 16 | let last_slash = url.lastIndexOf("/"); 17 | 18 | if (last_slash === -1) 19 | last_slash = url.lastIndexOf("\\"); 20 | 21 | if (last_slash === -1) 22 | return ""; // neither slash found, assume no path (e.g. "file.ext" returns "" as path) 23 | 24 | return url.substr(0, last_slash + 1); 25 | }; 26 | 27 | // Determine base URL of document. 28 | const baseElem = document.querySelector("base"); 29 | let baseHref = ((baseElem && baseElem.hasAttribute("href")) ? baseElem.getAttribute("href") : ""); 30 | 31 | // If there is a base href, ensure it is of the form 'path/' (not '/path', 'path' etc) 32 | if (baseHref) 33 | { 34 | if (baseHref.startsWith("/")) 35 | baseHref = baseHref.substr(1); 36 | 37 | if (!baseHref.endsWith("/")) 38 | baseHref += "/"; 39 | } 40 | 41 | function GetBaseURL() 42 | { 43 | return GetPathFromURL(location.origin + location.pathname) + baseHref; 44 | }; 45 | 46 | function FetchAs(url, responseType) 47 | { 48 | return new Promise((resolve, reject) => 49 | { 50 | const xhr = new XMLHttpRequest(); 51 | xhr.onload = (() => 52 | { 53 | if (xhr.status >= 200 && xhr.status < 300) 54 | { 55 | resolve(xhr.response); 56 | } 57 | else 58 | { 59 | reject(new Error("Failed to fetch '" + url + "': " + xhr.status + " " + xhr.statusText)); 60 | } 61 | }); 62 | xhr.onerror = reject; 63 | 64 | xhr.open("GET", url); 65 | xhr.responseType = responseType; 66 | xhr.send(); 67 | }); 68 | } 69 | 70 | function AddScriptTag(url) 71 | { 72 | return new Promise((resolve, reject) => 73 | { 74 | let elem = document.createElement("script"); 75 | elem.onload = resolve; 76 | elem.onerror = reject; 77 | elem.async = false; // preserve execution order 78 | elem.src = url; 79 | document.head.appendChild(elem); 80 | }); 81 | } 82 | 83 | function AddStylesheet(url) 84 | { 85 | return new Promise((resolve, reject) => 86 | { 87 | let elem = document.createElement("link"); 88 | elem.onload = resolve; 89 | elem.onerror = reject; 90 | elem.rel = "stylesheet"; 91 | elem.href = url; 92 | document.head.appendChild(elem); 93 | }); 94 | } 95 | 96 | // Look through a parent element's children for relevant nodes (imports, style, script) 97 | function FindImportElements(parentElem, context) 98 | { 99 | for (let i = 0, len = parentElem.children.length; i < len; ++i) 100 | { 101 | CheckForImportElement(parentElem.children[i], context); 102 | } 103 | } 104 | 105 | // Check if a given element is a relevant node (import, style, script) 106 | function CheckForImportElement(elem, context) 107 | { 108 | const tagName = elem.tagName.toLowerCase(); 109 | 110 | if (tagName === "link") 111 | { 112 | const rel = elem.getAttribute("rel").toLowerCase(); 113 | const href = elem.getAttribute("href"); 114 | 115 | if (rel === "import") 116 | { 117 | context.dependencies.push({ 118 | type: "import", 119 | url: context.baseUrl + href 120 | }); 121 | } 122 | else if (rel === "stylesheet") 123 | { 124 | context.dependencies.push({ 125 | type: "stylesheet", 126 | url: context.baseUrl + href 127 | }); 128 | } 129 | else 130 | { 131 | console.warn("[HTMLImports] Unknown link rel: ", elem); 132 | } 133 | } 134 | else if (tagName === "script") 135 | { 136 | // Map the full script src to its import document for GetImportDocument(). 137 | const scriptUrl = context.baseUrl + elem.getAttribute("src"); 138 | scriptUrlToImportDoc.set(new URL(scriptUrl, GetBaseURL()).toString(), context.importDoc); 139 | 140 | context.dependencies.push({ 141 | type: "script", 142 | url: scriptUrl 143 | }); 144 | } 145 | } 146 | 147 | // Group an import's dependencies in to chunks we can load in parallel. 148 | // Basically this organises stylesheets in to a separate parallel chunk, then groups contiguous 149 | // script tags in to the same chunk. Imports still have to be run sequentially, but this allows 150 | // parallel loading of a lot of the script dependencies. 151 | function GroupDependencies(dependencies) 152 | { 153 | const stylesheets = []; 154 | const groups = []; 155 | let currentGroup = []; 156 | 157 | for (const dep of dependencies) 158 | { 159 | const type = dep.type; 160 | 161 | if (type === "stylesheet") 162 | { 163 | stylesheets.push(dep); 164 | } 165 | else if (!currentGroup.length) 166 | { 167 | currentGroup.push(dep); 168 | } 169 | else 170 | { 171 | const lastType = currentGroup[currentGroup.length - 1].type; 172 | 173 | if (lastType === "script" && type === "script") // group contiguous scripts 174 | { 175 | currentGroup.push(dep); 176 | } 177 | else 178 | { 179 | groups.push(currentGroup); 180 | currentGroup = [dep]; 181 | } 182 | } 183 | } 184 | 185 | if (currentGroup.length) 186 | groups.push(currentGroup); 187 | 188 | return { 189 | stylesheets, groups 190 | }; 191 | }; 192 | 193 | function _AddImport(url, preFetchedDoc, rootContext, progressObject) 194 | { 195 | let isRoot = false; 196 | 197 | // The initial import creates a root context, which is passed along to all sub-imports. 198 | if (!rootContext) 199 | { 200 | isRoot = true; 201 | rootContext = { 202 | alreadyImportedUrls: new Set(), // for deduplicating imports 203 | stylePromises: [], 204 | scriptPromises: [], 205 | progress: (progressObject || {}) // progress written to this object (loaded, total) 206 | }; 207 | 208 | rootContext.progress.loaded = 0; 209 | rootContext.progress.total = 1; // add root import 210 | } 211 | 212 | // Each import also tracks its own state with its own context. 213 | const context = { 214 | importDoc: null, 215 | baseUrl: GetPathFromURL(url), 216 | dependencies: [] 217 | }; 218 | 219 | // preFetchedDoc is passed for sub-imports which pre-fetch their documents as an optimisation. If it's not passed, 220 | // fetch the URL to get the document. 221 | let loadDocPromise; 222 | 223 | if (preFetchedDoc) 224 | loadDocPromise = Promise.resolve(preFetchedDoc); 225 | else 226 | loadDocPromise = FetchAs(url, "document"); 227 | 228 | return loadDocPromise 229 | .then(doc => 230 | { 231 | // HACK: in Edge, due to this bug: https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/12458748/ 232 | // the fetched document URL is incorrect. doc.URL is also read-only so cannot directly be assigned. To work around this, 233 | // calculate the correct URL and use Object.defineProperty to override the returned document URL. 234 | Object.defineProperty(doc, "URL", { 235 | value: new URL(url, GetBaseURL()).toString() 236 | }); 237 | 238 | context.importDoc = doc; 239 | 240 | // Find all interesting top-level elements (style, imports, scripts) 241 | FindImportElements(doc.head, context); 242 | FindImportElements(doc.body, context); 243 | 244 | // Organise these dependencies in to chunks that can be loaded simultaneously. 245 | const organisedDeps = GroupDependencies(context.dependencies); 246 | 247 | // All style can start loading in parallel. Note we don't wait on completion for these until 248 | // the root import finishes. 249 | const stylePromises = organisedDeps.stylesheets.map(dep => AddStylesheet(dep.url)); 250 | rootContext.stylePromises.push(...stylePromises); 251 | 252 | // Start fetching all sub-imports in parallel, to avoid having to do a round trip for each one. 253 | // Map the import URL to a promise of its fetch, so we can easily wait for its load. 254 | const subImportFetches = new Map(); 255 | 256 | for (const group of organisedDeps.groups) 257 | { 258 | const type = group[0].type; 259 | 260 | if (type === "import") 261 | { 262 | const url = group[0].url; 263 | 264 | if (!rootContext.alreadyImportedUrls.has(url)) 265 | { 266 | subImportFetches.set(url, FetchAs(url, "document")); 267 | rootContext.alreadyImportedUrls.add(url); 268 | rootContext.progress.total++; 269 | } 270 | } 271 | } 272 | 273 | // Load each chunk simultaneously. This allows groups of contiguous scripts to start loading 274 | // simultaneously. However to preserve order of script execution, additional imports must be 275 | // waited on to resolve (meaning its own scripts have started loading) before we start loading 276 | // any later scripts in this import. 277 | let ret = Promise.resolve(); 278 | 279 | for (const group of organisedDeps.groups) 280 | { 281 | const type = group[0].type; 282 | 283 | // Imports should be on their own, since they cannot be loaded simultaneously. 284 | if (type === "import") 285 | { 286 | if (group.length !== 1) 287 | throw new Error("should only have 1 import"); 288 | 289 | // Wait for the text pre-fetch to complete, then load the import 290 | // and wait for its load to finish before loading anything after it. 291 | const url = group[0].url; 292 | ret = ret.then(() => 293 | { 294 | const importFetch = subImportFetches.get(url); 295 | if (!importFetch) 296 | return null; // de-duplicated 297 | 298 | return importFetch.then(importDoc => 299 | { 300 | // HACK: same doc.URL bug workaround as used above. 301 | Object.defineProperty(importDoc, "URL", { 302 | value: new URL(url, GetBaseURL()).toString() 303 | }); 304 | 305 | return _AddImport(url, importDoc, rootContext); 306 | }) 307 | .then(() => rootContext.progress.loaded++); 308 | }); 309 | } 310 | else if (type === "script") 311 | { 312 | // Wait for any prior imports to resolve, then commence loading of all scripts in this 313 | // group simultaneously. This allows parallel loading but guarantees sequential order 314 | // of execution. 315 | ret = ret.then(() => 316 | { 317 | const scriptPromises = group.map(dep => AddScriptTag(dep.url)); 318 | rootContext.scriptPromises.push(...scriptPromises); 319 | 320 | // In crash reports, somehow the AddImport() promise can resolve before all the scripts 321 | // have loaded. Currently it's not known how this could happen; the root import clearly 322 | // waits for all promises in rootContext.scriptPromises to resolve before continuing. 323 | // As a shotgun hack to try to work around this, force the root-level scripts to finish 324 | // sequentially before continuing. This has negligible performance impact locally but 325 | // ought to provide a stronger guarantee that scripts have loaded before continuing. 326 | if (isRoot) 327 | return Promise.all(scriptPromises); 328 | else 329 | return Promise.resolve(); 330 | }); 331 | } 332 | else 333 | throw new Error("unknown dependency type"); 334 | } 335 | 336 | return ret; 337 | }) 338 | .then(() => 339 | { 340 | // To speed up sub-imports, they don't wait for the scripts or stylesheets they add to finish 341 | // before resolving. The root level import waits, to ensure they can all complete in parallel 342 | // without unnecessarily holding up the loading of other sub-imports. 343 | if (isRoot) 344 | { 345 | return Promise.all([...rootContext.stylePromises, ...rootContext.scriptPromises]) 346 | .then(() => rootContext.progress.loaded++); // count root as loaded 347 | } 348 | else 349 | { 350 | return Promise.resolve(); 351 | } 352 | }) 353 | .then(() => context.importDoc) // resolve with the main import document added 354 | .catch(err => 355 | { 356 | console.error("[HTMLImports] Unable to add import '" + url + "': ", err); 357 | }) 358 | } 359 | 360 | function AddImport(url, async, progressObject) 361 | { 362 | // Note async attribute ignored (was only used for old native implementation). 363 | return _AddImport(url, null, null, progressObject); 364 | } 365 | 366 | function AssociateScriptPathWithImport(scriptUrl, importDoc) 367 | { 368 | const fullUrl = new URL(scriptUrl, GetBaseURL()).toString(); 369 | 370 | if (scriptUrlToImportDoc.has(fullUrl)) 371 | console.warn("[HTMLImports] Already have an import associated with script URL: " + fullUrl); 372 | 373 | scriptUrlToImportDoc.set(fullUrl, importDoc); 374 | } 375 | 376 | function GetImportDocument() 377 | { 378 | // Use our map of script to import document. 379 | const currentScriptSrc = document.currentScript.src; 380 | const importDoc = scriptUrlToImportDoc.get(currentScriptSrc); 381 | 382 | if (importDoc) 383 | { 384 | return importDoc; 385 | } 386 | else 387 | { 388 | console.warn("[HTMLImports] Don't know which import script belongs to: " + currentScriptSrc); 389 | return document; 390 | } 391 | } 392 | 393 | window["addImport"] = AddImport; 394 | window["associateScriptPathWithImport"] = AssociateScriptPathWithImport; 395 | window["getImportDocument"] = GetImportDocument; 396 | } --------------------------------------------------------------------------------