On webpack how can I import a script without evaluate it? - javascript

I'm recently working on some website optimization works, and I start using code splitting in webpack by using import statement like this:
import(/* webpackChunkName: 'pageB-chunk' */ './pageB')
Which correctly create the pageB-chunk.js, now let's say I want to prefetch this chunk in pageA, I can do it by add this statement in pageA:
import(/* webpackChunkName: 'pageB-chunk' */ /* webpackPrefetch: true */ './pageB')
Which will result in a
<link rel="prefetch" href="pageB-chunk.js">
being append to HTML's head, then the browser will prefetch it, so far so good.
The problem is the import statement I use here not just prefetch the js file, but also evaluate the js file, means the code of that js file is parsed & compile to bytecodes, the top-level code of that JS is executed.
This is a very time-consuming operation on a mobile device and I want to optimize it, I only want the prefetch part, I don't want the evaluate & execute part, because later when some user interactions happen, I will trigger the parsing & evaluate myself
↑↑↑↑↑↑↑↑ I only want to trigger the first two steps, pictures come from https://calendar.perfplanet.com/2011/lazy-evaluation-of-commonjs-modules/ ↑↑↑↑↑↑↑↑↑
Sure I can do this by adding the prefetch link myself, but this means I need to know which URL I should put in the prefetch link, webpack definitely knows this URL, how can I get it from webpack?
Does webpack have any easy way to achieve this?

UPDATE
You can use preload-webpack-plugin with html-webpack-plugin it will let you define what to preload in configuration and it will automatically insert tags to preload your chunk
note if you are using webpack v4 as of now you will have to install this plugin using preload-webpack-plugin#next
example
plugins: [
new HtmlWebpackPlugin(),
new PreloadWebpackPlugin({
rel: 'preload',
include: 'asyncChunks'
})
]
For a project generating two async scripts with dynamically generated
names, such as chunk.31132ae6680e598f8879.js and
chunk.d15e7fdfc91b34bb78c4.js, the following preloads will be injected
into the document head
<link rel="preload" as="script" href="chunk.31132ae6680e598f8879.js">
<link rel="preload" as="script" href="chunk.d15e7fdfc91b34bb78c4.js">
UPDATE 2
if you don't want to preload all async chunk but only specific once you can do that too
either you can use migcoder's babel plugin or with preload-webpack-plugin like following
first you will have to name that async chunk with help of webpack
magic comment example
import(/* webpackChunkName: 'myAsyncPreloadChunk' */ './path/to/file')
and then in plugin configuration use that name like
plugins: [
new HtmlWebpackPlugin(),
new PreloadWebpackPlugin({
rel: 'preload',
include: ['myAsyncPreloadChunk']
})
]
First of all let's see the behavior of browser when we specify script tag or link tag to load the script
whenever a browser encounter a script tag it will load it parse it
and execute it immediately
you can only delay the parsing and evaluating with help of async and
defer tag only until DOMContentLoaded event
you can delay the execution (evaluation) if you don't insert the script tag ( only preload it with link)
now there are some other not recommended hackey way is you ship your entire script and string or comment ( because evaluation time of comment or string is almost negligible) and when you need to execute that you can use Function() constructor or eval both are not recommended
Another Approach Service Workers: ( this will preserve you cache event after page reload or user goes offline after cache is loaded )
In modern browser you can use service worker to fetch and cache a recourse ( JavaScript, image, css anything ) and when main thread request for that recourse you can intercept that request and return the recourse from cache this way you are not parsing and evaluating the script when you are loading it into the cache
read more about service workers here
example
self.addEventListener('install', function(event) {
event.waitUntil(
caches.open('v1').then(function(cache) {
return cache.addAll([
'/sw-test/',
'/sw-test/index.html',
'/sw-test/style.css',
'/sw-test/app.js',
'/sw-test/image-list.js',
'/sw-test/star-wars-logo.jpg',
'/sw-test/gallery/bountyHunters.jpg',
'/sw-test/gallery/myLittleVader.jpg',
'/sw-test/gallery/snowTroopers.jpg'
]);
})
);
});
self.addEventListener('fetch', function(event) {
event.respondWith(caches.match(event.request).then(function(response) {
// caches.match() always resolves
// but in case of success response will have value
if (response !== undefined) {
return response;
} else {
return fetch(event.request).then(function (response) {
// response may be used only once
// we need to save clone to put one copy in cache
// and serve second one
let responseClone = response.clone();
caches.open('v1').then(function (cache) {
cache.put(event.request, responseClone);
});
return response;
}).catch(function () {
// any fallback code here
});
}
}));
});
as you can see this is not a webpack dependent thing this is out of scope of webpack however with help of webpack you can split your bundle which will help utilizing service worker better

Updates:
I include all the things into a npm package, check it out!
https://www.npmjs.com/package/webpack-prefetcher
After few days of research, I end up with writing a customize babel plugin...
In short, the plugin work like this:
Gather all the import(args) statements in the code
If the import(args) contains /* prefetch: true */ comment
Find the chunkId from the import() statement
Replace it with Prefetcher.fetch(chunkId)
Prefetcher is a helper class that contain the manifest of webpack output, and can help us on inserting the prefetch link:
export class Prefetcher {
static manifest = {
"pageA.js": "/pageA.hash.js",
"app.js": "/app.hash.js",
"index.html": "/index.html"
}
static function fetch(chunkId) {
const link = document.createElement('link')
link.rel = "prefetch"
link.as = "script"
link.href = Prefetcher.manifest[chunkId + '.js']
document.head.appendChild(link)
}
}
An usage example:
const pageAImporter = {
prefetch: () => import(/* prefetch: true */ './pageA.js')
load: () => import(/* webpackChunkName: 'pageA' */ './pageA.js')
}
a.onmousehover = () => pageAImporter.prefetch()
a.onclick = () => pageAImporter.load().then(...)
The detail of this plugin can found in here:
Prefetch - Take control from webpack
Again, this is a really hacky way and I don't like it, if u want webpack team to implement this, pls vote here:
Feature: prefetch dynamic import on demand

Assuming I understood what you're trying to achieve, you want to parse and execute a module after a given event (e.g click on a button). You could simply put the import statement inside that event:
element.addEventListener('click', async () => {
const module = await import("...");
});

Related

Why doesn't Webpack emit chunks when using webpackMode: "weak"?

I'm transitioning a legacy app to Webpack. I'm using Webpack 5.56 (latest at time of writing).
My app is localised and I have a folder with a handful of locale files,
locales
- locale.en.ts
- locale.de.ts
- etc
Each of these locale files is an ES module and they all export (different implementations of) the same functions — getText, printNumber, etc. I have a wrapper module which dynamically imports the correct locale for the current user:
// localization.ts
interface LocaleModule {
getText(text: string): string;
// etc
}
let module: LocaleModule;
import(`locales/locale.${currentUser.language}`).then(m => {
module = m;
});
export function getText(text: string): string {
return module.getText(text);
}
I know the current user's language when the page is being rendered. I want to include the correct locale.*.js script as an initial chunk, so that:
The browser doesn't have to wait for the main chunk to load before it can start downloading the locale file.
The functions in localization.ts can be synchronous.
This seemed like it'd be a good fit for webpackMode: "weak", since I'd like to get an error in the console if the locale file is missing for whatever reason (rather than silently degrade performance). The docs seem to explicitly call out my use case:
This is useful for universal rendering when required chunks are always manually served in initial requests (embedded within the page).
Here's my code:
let module: LocaleModule;
import(
/* webpackMode: "weak" */
/* webpackChunkName: "locales/[request]" */
`./locales/locale.${currentUser.language}`
).then(m => {
module = m;
});
However, it seems webpackMode: "weak" causes Webpack to emit no chunks for the referenced modules at all. There aren't any locale files in Webpack's output folder. I can’t very well include a chunk in the HTML if it was never emitted!
What's the reason for this behaviour? Is there a clean way to get Webpack to emit chunks for dynamically imported modules but not download them asynchronously? (I know that I could use webpackMode: "lazy" and just include the chunk upfront in a script tag, but I'd like to get an error if the locale file is missing.) Or do I have an XY problem, and there’s some better way to do this which I’m unaware of?
I have similar issues and resolve this.
My local file looks like:
import dictionary from './locales/en.json'
const en = dictionary
window.__default_dictionary__ = en
module.exports = en
My locales structure looks like:
enter image description here
You must add new cacheGroup for splitChunks.cacheGroups in webpack config
locales: {
enforce: true,
reuseExistingChunk: true,
priority: 50,
chunks: 'all',
test(module) {
if (/[\\/]src\/i18n[\\/]/.test(module.resource)) return true
return false
},
name(module) {
const moduleFileName = module
.identifier()
.split('/')
.reduceRight((item) => item)
.replace('.json', '')
.replace('.js', '')
return `locales~${moduleFileName}`
},
},
Now all of your locales files will be extracted to another chunk files.
You can use any handler for load locales, for example:
loadLocaleHandler: async (locale: Locale) => {
let localeModule: { default: Dictionary } = await import(`i18n/${locale}`)
return localeModule.default
},
And for everything to work correctly you must
Add locale chunk for result html
<script src="/assets/webpack/js/runtime.js" defer="defer"></script>
<script src="/assets/webpack/js/vendors.js" defer="defer"></script>
<!-- For example it maybe value from cookie or context of app -->
<script src="/assets/webpack/js/locales~(en|it|es).chunk.js" defer="defer"></script>
<script src="/assets/webpack/js/entry.js" defer="defer"></script>
Add magic webpack code to entry point
const defaultLocale: Locale = cookies.getItem('locale') || process.env.DEFAULT_LOCALE
if (__webpack_modules__[`./src/i18n/${defaultLocale}.js`]) {
__webpack_require__(`./src/i18n/${defaultLocale}.js`)
}
Totally:
you don't need wait loading locales by runtime import for first request
you can organize locales for multilocale and multidomain app
all of your locales remain dynamic modules and can be loaded at runtime
I can't post such long comment, so it has to be an answer...
So it looks like there isn't a real link between the modules and the bundler can't resolve them compile time so they aren't emitted. The only think I changed in your code is how modules are imported and it worked out of the box:
const langCode = getLangCode();
let mod;
import("./locales/locale.en")
switch (langCode) {
case "en":
import(`./locales/locale.en.js`).then(m => {
mod = m;
console.log("loaded locale");
})
break;
case "de":
import(`./locales/locale.de.js`).then(m => {
mod = m;
console.log("loaded locale");
})
break;
default:
}
export function getText(text) {
return mod.getText(text);
}
function getLangCode() {
return "de";
}
I know the switch case is not ideal, but the bundler can't automatically guess that pattern: ./locales/locale.${langCode}.js and add all files in the directory that match .js.
The doc says the following:
'weak': Tries to load the module if the module function has already been loaded in some other way (e.g. another chunk imported it or a script containing the module was loaded). A Promise is still returned, but only successfully resolves if the chunks are already on the client. If the module is not available, the Promise is rejected. A network request will never be performed. This is useful for universal rendering when required chunks are always manually served in initial requests (embedded within the page), but not in cases where app navigation will trigger an import not initially served.
From what I understand this means the chunks are expected to be already on the page and generated through some other means.
I hope that helps you resolve your issue.
In order to use weak you have to already manually served the chunks as stated in the docs. This means that adding it in a dynamic import as comment does not create any chunks (in contradiction with lazy and lazy-once).
Is there a clean way to get Webpack to emit chunks for dynamically imported modules but not download them asynchronously?
For synchronous loading:
You can either:
Use webpackMode: "lazy" and include the chunk upfront in a script tag as you stated (the Promise returned is rejected in case of missing chunk).
You can define the locale js files as dynamic entry points and load them manually by yourself.
For your example, creating an entrypoint for each locale could be something like:
const glob = require('glob')
module.exports = {
devtool: false,
entry: {
...glob.sync('./src/locales/*').reduce((acc, module) => {
const name = module.replace('./src/locales/', '').replace('.js', '')
acc[name] = module
return acc
}, {})
}
};
This would emit locale.de.js and locale.en.js bundles and then you should somehow manually load a <script defer src="locale.<locale>.js"></script>, but that depends on how you serve your app.
For asynchronous loading:
You can use webpackMode: "lazy" along with webpackPreload: true in order to decouple main and locale chunk requests.
As stated in the docs
A preloaded chunk starts loading in parallel to the parent chunk.

Loading a third-party javascript library

I developed a web radio player using Vue Cli. Now I have to add a new functionality using an external library (it's aimed to handle audio advertising) and this library must be served from the remote host. I can't just download the library and import it locally.
Setup
In my Vue App I have several components and in one of them I have an audio tag handling the radio playback. I need to detect the click on the play button, load the ad, play it and then go to the radio regular playback.
Approachs I tried
Loading the external library in the index.html file. It works but I can't interact with the player being loaded in Vue. For example, if I try to listen to the play event in the index.html file (audio.addEventListener("play", onPlay);, I just receive "audio not defined" in the web console.
Loading the external library in the mounted () section of my component:
const triton = document.createElement('script')
triton.setAttribute('src', '//sdk.listenlive.co/web/2.9/td-sdk.min.js')
document.head.appendChild(triton)
this.initPlayerSDK()
triton.onload = () => {
let player = new TDSdk(this.tdPlayerConfig)
console.log(player)
}
The problem with this approach is that after npm run serveI receive the message 'TDSdk' is not defined which makes complete sense. I'm loading the external JS file but webpack isn't interpreting its content because that it's done in runtime. I have to add the external in my vue.config.js, but this doesn't work neither:
vue.config.js
const path = require('path')
module.exports = {
publicPath: './',
/*configureWebpack: {
externals: {
tdSdk: 'tdSdk'
}
},*/
chainWebpack: config => {
config.module
.rule('images')
.test(/\.(png|jpe?g|gif|webp)(\?.*)?$/)
.use('url-loader')
.loader('file-loader') // not url-loader but file-loader !
.tap((options) => { // not .option() but .tap(options...)
// modify the options...
options.name = 'img/[name].[ext]'
return options
}),
config.externals([
{
'tdSdk': 'TDSdk'
},
])
},
css: {
loaderOptions: {
sass: {
sassOptions: {
includePaths: [path.resolve(__dirname, './node_modules/compass-mixins/lib')]
}
}
}
},
externals: {
tdSdk: 'TDSdk'
}
}
myComponent.vue
import tdSdk from 'tdSdk'
My solution was to load the library in the public/index.html file and then wait for the DOM being loaded so I could add the event listener to the audio element already loaded:
document.addEventListener('DOMContentLoaded', function() {
var playControl = document.getElementById('playIcon');
playControl.addEventListener("click", onPlay);
}
Then, in the Vuex store I needed to access the variables defined in the javascript located in the index.html. To do that, I set the window.adState (the var I'm using) as a global var in my store file:
Vuex.Store.prototype.$adState = window.adState
Finally, in my actions/mutations I used this.$adState to check its content:
playPause ({ commit, dispatch }) {
console.log('AdState', this.$adState)
(...)
}
Answer added on behalf of OP.
The import cannot be resolved at the time when the script is evaluated because TDSdk global is not available. A script that is dynamically added to head is loaded asynchronously so there will be race condition any way.
<script> needs to be added dynamically if there's dynamic behaviour involved or like a condition or a developer doesn't have control over page layout. For static script, Vue CLI project's public index.html can be modified:
<body>
<div id="app"></div>
<script src="//sdk.listenlive.co/web/2.9/td-sdk.min.js"></script>
<!-- built files will be auto injected -->
</body>
Application bundle is evaluated after the script and the global is expected to be available there.
Externals are commonly used for packages that were swapped to ones that were externally loaded, usually from CDN. Since tdSdk is not a real package and has no prospects to be swapped for one, it doesn't serve a good purpose to map it to a global and import it. It can be just used as TDSdk global in the application.

Load CycleJS with SystemJS

I tried to load Cycle DOM from their CDN through SystemJS with something like:
System.config({
map: {
'cycle-dom': 'https://unpkg.com/#cycle/dom#17.1.0/dist/cycle-dom.js',
'xstream': 'https://cdnjs.cloudflare.com/ajax/libs/xstream/10.3.0/xstream.min.js',
}
});
System.import('cycle-dom', cycleDOM => {
...
});
But I quickly found out cycle-dom needs xstream. So I try to load both:
Promise.all([
System.import('xstream'),
System.import('cycle-dom')
])
.then(([xs, cycleDOM]) => {
...
});
But I still get the same error. It looks like cycle-dom is expecting xstream to exist on window when it's first loaded. So I tried:
System.import('xstream')
.then(xs => window['xstream'] = xs)
.then(() => System.import('cycle-dom'))
.then(cycleDOM => {
...
});
I feel like I'm going about this all wrong. How can I do this?
Update:
Following martin's advice below, I tried configuring xstream as a dependency of cycle-dom.
Here's a jsbin that demonstrates. What I'm doing is loading cycle-run and cycle-dom and then running the example off the cycle home page.
But I get the error:
"TypeError: Cannot read property 'default' of undefined"
Undefined in this case is cycle-dom trying to load window['xstream'], which isn't being loaded.
Thanks.
The System.import() call returns a Promise so you need to put the callback into its then() method (the second parameter is the parent name; not a callback).
System.import('cycle-dom').then(function(cycleDOM) {
console.log(cycleDOM);
});
This prints the module exports.
I don't have any experience with cycle.js so I can't tell whether this is enough or not. Nonetheless you can set this package dependencies with meta config:
System.config({
map: {
'cycle-dom': 'https://unpkg.com/#cycle/dom#17.1.0/dist/cycle-dom.js',
'xstream': 'https://cdnjs.cloudflare.com/ajax/libs/xstream/10.3.0/xstream.min.js',
},
meta: {
'cycle-dom': {
deps: [
'xstream'
]
}
}
});
Again, I don't know whether this is enough or not. The SystemJS documentation contains pretty well explained example how to load dependencies that need to register some global variables. See https://github.com/systemjs/systemjs/blob/master/docs/module-formats.md#shim-dependencies
Edit:
In this case it's a little more complicated. The cycle-run.js script is generated probably by browserify and you can see it contains a line as follows:
var xstream_1 = (typeof window !== "undefined" ? window['xstream'] : typeof global !== "undefined" ? global['xstream'] : null);
This checks whether window['xstream'] exists when it's loaded. This means that the xstream has to be loaded before loading the cycle-run.js script. The way SystemJS works is that it loads the requested module and then loads its dependencies (you can see the order in Developer Tools). So it's the opposite order than you need (this is very similar to my question on SystemJS GitHub page).
This means you need to restructure the import calls:
System.config({
// ...
meta: {
'xstream': {
format: 'global',
exports: 'xstream',
}
}
});
System.import('xstream').then(function() {
Promise.all([
System.import('cycle-run'),
System.import('cycle-dom'),
])
.then(([cycle, cycleDOM]) => {
// ...
});
});
This registers the xstream before loading cycle-run. Also with the meta configuration for xstream this ensures that the window.xstream exists only inside these callbacks and doesn't leak to the global scope.
See your updated demo: https://jsbin.com/qadezus/35/edit?js,output
Also to use format and exports you need to use the newer SystemJS 0.20.* and not 0.19.*.

Using webpack to build a dependency before compiling the build

I'm using inline styles & my theme comes from a file called themes.json, which I create & inject into my build folder before calling webpack. I'd like webpack to handle this, too.
My first attempt was to use a plugin:
compiler.plugin('compilation', async(compilation, callback) => {
const themes = {}; // empty for this example
compilation.assets['themes.json'] = {
source: function() {
return themes;
},
size: function() {
return themes.length;
}
};
callback();
}
However, since plugins run async or parallel, I come across a race condition where my index.js that has a const themes = require('../build/themes.json') is trying to require something before it exists.
How can I make sure themes.json exists and is useable within my build? I see 3 possibilities:
There's a way to run plugins in serial that I don't know about
I somehow use a loader to perform this
I write a plugin that looks for require('../build/themes.json') and when it finds it, it creates & injects the themes.
Any help on the right way to do this?

Webpack: unable to require automatically resolved dependencies using variables

While working on a Web app using Webpack to manage JavaScript dependencies, I stumbled upon the problem i'm going to describe.
Loading dependencies passing strings to require() works beautifully:
// main.js
var jQuery = require('jquery');
Here, jquery is installed with Bower, and Webpack is correctly configured to automatically resolve Bower modules.
Now, I'm working on the problem of conditionally loading modules, with particular regard to the situation where modules have to be downloaded from a CDN, or from the local server if the CDN fails. I use scriptjs to asynchronously load from the CDN, by the way. The code I'm writing is something like this:
var jQuery = undefined;
try {
jQuery = require('jquery-cdn');
} catch (e) {
console.log('Unable to load jQuery from CDN. Loading local version...');
require('script!jquery');
jQuery = window.jQuery;
}
// jQuery available here
and this code works beautifully as well.
Now, since I obviously have a lot of dependencies (Handlebars, Ember, etc.) that I want to try to load from a CDN first, this code starts to get a little redundant, so the most logical thing I try to do is to refactor it out into a function:
function loadModule(module, object) {
var lib = undefined;
try {
lib = require(module + '-cdn');
} catch (e) {
console.log('Cannot load ' + object + ' from CDN. Loading local version...');
require('script!' + module);
lib = window[object];
}
return lib;
}
var jQuery = loadModule('jquery', 'jQuery');
var Handlebars = loadModule('handlebars', 'Handlebars');
// etc...
The problem is that Webpack has a particular behaviour when dealing with expressions inside require statements, that hinders my attempts to load modules in the way described above. In particular, when using an expression inside require it
tries to include all files that are possible with your expression
The net effect is a huge pile of error messages when I try to run Webpack with the above code.
Though the linked resources suggest to explicitly declare the path of the JavaScript files to include, what I fail to get is how to do the same thing when I cannot, or don't want to, pass a precise path to require, but rather use the automatically resolved modules, as shown.
Thanks all
EDIT:
I still don't known how to use expressions to load those scripts, however, I designed a workaround. Basically, the idea is to explicitly write the require('script') inside a callback function, and then dinamically call that function when it's time. More precisely, I prepared a configuration file like this:
// config.js
'use strict';
module.exports = {
'lib': {
'jquery': {
'object': 'jQuery',
'dev': function() { require('script!jquery'); },
'dist': function() { return require('jquery-cdn'); },
'cdn': '//ajax.googleapis.com/ajax/libs/jquery/1.11.1/jquery.min.js'
},
'handlebars': {
// ...
}
}
};
Inside my main code I, then, define an array of resources to load, like:
var config = require('./config.js');
var resources = [ config.lib.jquery, config.lib.handlebars, ... ];
And then when I have to load the development version, or the distribution version, I dinamically call:
// Inside some kind of cycle
// resource = resources[index]
try {
window[resource.object] = resource.dist();
} catch (e) {
console.log('Cannot load ' + resource.object + ' from CDN. Loading local version...');
resource.dev();
}
Here there's a more complete example of this in action.

Categories