BuilderIO / partytown
- понедельник, 27 сентября 2021 г. в 00:28:23
Relocate resource intensive third-party scripts off of the main thread and into a web worker. 🎉
Introducing Partytown: Run Third-Party Scripts From a Web Worker
A fun location for your third-party scripts to hang out
Partytown is a lazy-loaded 6kb
library to help relocate resource intensive scripts into a web worker, and off of the main thread. Its goal is to help speed up sites by dedicating the main thread to your code, and offloading third-party scripts to a web worker.
Even with a fast and highly tuned site and/or app following all of the best practices, it's all too common for your performance wins to be erased the moment third-party scripts are added. By third-party scripts we mean code that is embedded within your site, but not directly under your control. A few examples include: analytics, metrics, ads, A/B testing, trackers, etc. Their inclusion are often a double-edged sword.
Below is a summary of potential issues, referenced from Loading Third-Party JavaScript:
document.write()
), which are known to be harmful to the user experience.window.onload
, even if the embed is using async or defer.We set out to solve this situation, so that apps of all sizes will be able to continue to use third-party scripts without the performance hit. Some of Partytown's goals include:
Partytown's philosophy is that the main thread should be dedicated to your code, and any scripts that are not required to be in the critical path should be moved to a web worker. Main thread performance is, without question, more important than web worker thread performance. See the example page and test pages for some live demos.
If you're looking to run your app within a web worker, we recommend the WorkerDom project.
Traditionally, communicating between the main thread and worker thread must be asynchronous. Meaning that for the two threads to communicate, they cannot use blocking calls.
Party town is different. It allows code executed from the web worker to access DOM synchronously. The benefit from this is that third-party scripts can continue to work exactly how they're coded.
For example, the code below works as expected within a web worker:
const rect = element.getBoundingClientRect();
console.log(rect.x, rect.y);
First thing you'll notice is that there's no async/await, promise or callback. Instead, the call to getBoundingClientRect()
is blocking, and the returned rect
value contains the expected x
and y
properties.
Additionally, data passed between the main thread and web worker must be serializable. Partytown automatically handles the serializing and deserializing of data passed between threads.
Third-party scripts are often a black-box with large amounts of code. What's buried within the obfuscated code is difficult to tell. It's minified for good reason, but regardless it becomes very difficult to understand what third-party scripts are executing on your site and your user's devices.
Partytown on the other hand, is able to isolate and sandbox third-party scripts within a web worker, and allow, or deny, access to main thread APIs. This includes cookies, localStorage, or the entire document. Because the code is executed within the worker, and their access to the main thread must go through the proxy, Partytown is able to give developers control over what the scripts that can execute.
Essentially, Partytown lets you:
With debug and logging enabled, below is an example of the Partytown logs showing all calls, getters and setters:
Nothing is without trade-offs. Using Partytown to orchestrate third-party scripts vs adding them to your pages has the following considerations to keep in mind:
event.preventDefault()
will have no effect, similar to passive event listeners.0 bytes
over the network.main
has no impact on Lighthouse).Below are just a few examples of third-party scripts that might be a good candidate to run from within a web worker. The goal is to continue validating commonly used services to ensure Partytown has the correct API proxies, but Partytown itself should not hardcode to any specific services. Help us test!
Partytown relies on Web Workers, Service Workers, JavaScript Proxies, and a communication layer between them all.
type="text/partytown"
attribute on the <script/>
tag.onfetch
handler to intercept specific requests.Atomics are the latest and greatest way to accomplish the challenge of synchronously sending data between the main thread and the web worker. Honestly, it looks like Atomics may be the preferred and "correct" way to perform these tasks. However, as of right now, more research is needed into how Atomics could be used in production.
Currently, Safari does not support Atomics due to Spectre Attacks: Exploiting Speculative Execution. When Spectre attacks were first documented, the other browsers removed Atomics too, but they have since added it back. Due to this uncertainty, we're opting for a solution that works everywhere, today. That said, we'd love to do more research here and hopefully migrate to use Atomics in the future, and use the current system as the fallback.
If the browser does not support any of the features above, then it'll fallback to run third-party scripts the traditional way.
A React <Partytown/>
component is provided within @builder.io/partytown/react. The component is simply a wrapper to the vanilla HTML example below, and similarly should be added within the document's <head>
. Below is an example of the React <Partytown/>
component used within a Next.js Document.
import { Partytown } from '@builder.io/partytown/react';
import Document, { Html, Head, Main, NextScript } from 'next/document';
export default class MyDocument extends Document {
render() {
return (
<Html>
<Head>
<Partytown />
</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
);
}
}
The <Partytown/>
component also has all of the configuration properties, such as:
<Partytown debug={true} />
Partytown provides some of the most common analytics as React components, just for added simplicity and convenience.
<GoogleTagManager/>
Below is an example of adding Partytown's Google Tag Manager React component to a Next.js Document:
import { Partytown, GoogleTagManager, GoogleTagManagerNoScript } from '@builder.io/partytown/react';
import Document, { Html, Head, Main, NextScript } from 'next/document';
export default class MyDocument extends Document {
render() {
return (
<Html>
<Head>
<GoogleTagManager containerId={'GTM-XXXXX'} />
<Partytown />
</Head>
<body>
<GoogleTagManagerNoScript containerId={'GTM-XXXXX'} />
<Main />
<NextScript />
</body>
</Html>
);
}
}
The React components are simply wrappers for convenience, but Partytown itself can be used from any framework, or no framework at all by just updating HTML.
Each third-party script that shouldn't run in the main thread, but instead party type
attribute of its opening script tag to text/partytown
. This does two things:
<script type="text/partytown">
// Third-party analytics scripts
</script>
To load Partytown with just HTML, the library script below should be added within the <head>
of page. The snippet will patch any global variables needed so other library scripts, such as Google Tag Manager's Data Layer, continues to work. However, the actual Partytown library, and any of the third-party scripts, are not downloaded or executed until after the document has loaded.
<!--Partytown-->
<script>
(function(){var t,e=window,p=document,a=e.partytown||{},n=p.createElement("script");e._ptf=[],(a.forward||[]).map((p=>{t=e,p.split(".").map(((a,n,r)=>{t=t[a]=n<r.length-1?t[a]||{}:function(){e._ptf.push(p,arguments)}}))})),n.async=n.defer=!0,n.src="/~partytown/partytown."+(a.debug?"debug.js":"js"),p.head.appendChild(n)})();
</script>
<!--End Partytown-->
Note that both the web worker script and the service worker script must be hosted from the same origin as the HTML page, rather than a CDN. Additionally, the service worker must be hosted from a directory that ensures all HTML pages are within the scope for the service worker, so that all client-side requests from those pages are intercepted by Partytown. All other scripts can be hosted on any origin.
With scripts disabled from executing, the Partytown library can lazily begin loading and executing the scripts from inside a worker.
To set the config, add a <script>
with a partytown={};
global before the Partytown library script, such as:
<script>
partytown = {...};
</script>
An additional requirement is that the /~partytown/
directory should serve the static files found within @builder.io/partytown/lib. The quickest way is to copy the lib
directory into a public /~partytown
directory within your static server. Another option would be to set up a copy task within the project's bundler, or create a build step.
Below is an example of using Webpack's copy plugin to copy the source lib
directory found in the @builder.io/partytown package, to the public/~partytown/
directory:
const CopyPlugin = require('copy-webpack-plugin');
module.exports = {
plugins: [
new CopyPlugin({
patterns: [
{
from: path.join(__dirname, 'node_modules', '@builder.io', 'partytown', 'lib'),
to: path.join(__dirname, 'public', '~partytown'),
},
],
}),
],
};
Config Property | Description |
---|---|
debug |
When set to true , Partytown scripts are not inlined and not minified. |
forward |
An array of strings. See Forwarding Events. |
logCalls |
Log method calls (debug mode required) |
logGetters |
Log getter calls (debug mode required) |
logSetters |
Log setter calls (debug mode required) |
logImageRequests |
Log Image() src requests (debug mode required) |
logScriptExecution |
Log script executions (debug mode required) |
logSendBeaconRequests |
Log navigator.sendBeacon() requests (debug mode required) |
logStackTraces |
Log stack traces (debug mode required) |
Many third-party scripts provide a global variable which user code calls in order to send data to the service. For example, Google Tag Manager uses a Data Layer array, and by pushing data to the array, the data is then sent on to GTM. Because we're moving third-party scripts to a web worker, the main thread needs to know which variables to patch first, and when Partytown loads, it can then forward the event data on to the service.
The forward
config is an array of strings, with each string representing a variable that should be patched. Below is a vanilla example of setting up the forwarding for Google Tag Manager, Hubspot and Intercom:
<script>
partytown = {
forward: ['dataLayer.push', '_hsq.push', 'Intercom']
};
</script>
React Forward Config:
<Partytown forward={['dataLayer.push', '_hsq.push', 'Intercom']} />
Note that the React integration components will already add the forward configs to the Partytown library.
The distribution comes with multiple files:
/~partytown/partytown.js
partytown.js
could be inlined in the HTML instead to reduce an extra HTTP request.partytown-sw.js
: Minified service worker with inlined sandbox and web worker./~partytown/partytown.debug.js
/~partytown/partytown.js
.partytown-sw.debug.js
: Service worker with separate sandbox request.partytown-sandbox.debug.js
: Sandbox with separate web worker request.partytown-ww.debug.js
: Web worker as separate file, not inlined./~partytown/partytown-snippet.js