Engineering a Tampermonkey Script with Vite, Less, and Hot‑Reload
This article details how to transform an outdated internal bug‑tracking web page by creating a Tampermonkey userscript, setting up a Vite‑based development environment, integrating Less preprocessing, automating script generation with nodemon, and achieving hot‑reload functionality for seamless updates.
1. Motivation
Recently I encountered a problem: the company's internal bug‑tracking tool has an outdated web UI that lacks Chinese names for contacts and makes it difficult to locate users among many email addresses. Modifying the source code is unrealistic because the product is a private‑deployed open‑source solution and the company does not allow such custom changes.
Therefore, I decided to inject scripts into the existing page, either via a browser extension or a userscript. I chose the userscript approach using the Tampermonkey plugin to keep the solution lightweight while reinforcing my knowledge of JavaScript DOM APIs.
2. About Tampermonkey Userscripts
A basic userscript consists of metadata comments wrapped between // ==UserScript== and // ==/UserScript== , followed by JavaScript code. Important metadata fields include @match (the domains where the script runs) and @run-at (the execution timing, such as document‑end).
Below is a minimal demo:
// ==UserScript==
// @name script
// @namespace http://tampermonkey.net/
// @version 0.0.1
// @description This is a Tampermonkey script
// @author xxx
// @match *://baidu.com/*
// @run-at document-end
// @license MIT
// ==/UserScript==
(function () {
"use strict";
const script = document.createElement("script");
document.body.appendChild(script);
})();3. Problems Emerging
Initially the script was a single raw JavaScript file, but as it grew beyond two thousand lines, navigation became painful, variables were forgotten, and the code turned messy.
To address this, I decided to engineer the script, which required solving several key issues.
4. Key Points Analysis
1. Build Tool
We need to bundle the script as an IIFE. Common choices are webpack or vite . I selected vite because it provides a fast dev server for instant preview.
If you prefer a simpler bundler, rollup can also produce an IIFE.
2. CSS Pre‑processor
Instead of injecting a plain style tag with raw CSS, I want to write styles in less (or scss ) and have them compiled to CSS strings that can be inserted via innerHTML .
export const addStyle = (css: string) => {
const style = document.createElement('style');
style.type = 'text/css';
style.innerHTML = css;
document.getElementsByTagName('head')[0].appendChild(style);
};
addStyle(`
body {
width: 100%;
height: 100%;
}
`);3. Hot‑Update Effect
Running vite in dev mode starts a local server that watches file changes and triggers hot‑module replacement. By creating a script tag whose src points to the dev server's output, the Tampermonkey script can automatically fetch the latest bundle without manual copying.
// ==UserScript==
// @name script
// @namespace http://tampermonkey.net/
// @version 0.0.1
// @description This is a description
// @author xxx
// @match *://baidu.com/*
// @run-at document-end
// @license MIT
// ==/UserScript==
(function () {
"use strict";
const script = document.createElement('script');
script.src = "http://localhost:6419/dist/script.iife.js";
document.body.appendChild(script);
})();5. Starting the Project
Initialize a Vite project with yarn create vite or pnpm create vite . Then adjust vite.config.js to expose a dev server and output an IIFE library:
module.exports = {
server: {
host: 'localhost',
port: 6419,
},
build: {
minify: false,
outDir: 'dist',
lib: {
entry: 'src/main.ts',
name: 'script',
fileName: 'script',
formats: ['iife'],
},
},
resolve: {
alias: {
'@': '/src',
'@utils': '/src/utils',
'@enum': '/src/enum',
'@const': '/src/const',
'@style': '/src/style',
},
},
};Using CommonJS export allows the configuration to be imported by other scripts.
6. Less Conversion and Helper Scripts
Script gen-style-string.js reads style.less , compiles it with less.render , and writes a TypeScript module exporting the CSS string:
const less = require('less');
const fs = require('fs');
const path = require('path');
const styleContent = fs.readFileSync(path.resolve(__dirname, '../src/style.less'), 'utf-8');
less.render(styleContent).then(output => {
if (output.css) {
const code = `export default ` + `\
${output.css}`;
const relativePath = '../style/index.ts';
const filePath = path.resolve(__dirname, relativePath);
if (fs.existsSync(filePath)) {
fs.rm(filePath, () => {
fs.writeFileSync(path.resolve(__dirname, relativePath), code);
});
} else {
fs.writeFileSync(path.resolve(__dirname, relativePath), code);
}
}
});Another script merges the Tampermonkey metadata (stored in tampermonkey.config ) with the built bundle, using prettier for formatting.
const fs = require('fs');
const path = require('path');
const prettier = require('prettier');
const codeFilePath = '../dist/script.iife.js';
const configFilePath = '../tampermonkey.config';
const codeContent = fs.readFileSync(path.resolve(__dirname, codeFilePath), 'utf-8');
const tampermonkeyConfig = fs.readFileSync(path.resolve(__dirname, configFilePath), 'utf-8');
if (codeContent) {
const code = `${tampermonkeyConfig}\n${codeContent}`;
prettier.format(code, { parser: 'babel' }).then(formatted => {
fs.writeFileSync(path.resolve(__dirname, codeFilePath), formatted);
});
}A third script generates the final tampermonkey.js by inserting the dev server address into the script tag:
const fs = require('fs');
const path = require('path');
const prettier = require('prettier');
const viteConfig = require('../vite.config');
const codeFilePath = '../tampermonkey.js';
const tampermonkeyConfig = fs.readFileSync(path.resolve(__dirname, '../tampermonkey.config'), 'utf-8');
const hostPort = `${viteConfig.server.host}:${viteConfig.server.port}`;
const codeContent = `
(function () {
'use strict';
const script = document.createElement('script');
script.src = 'http://${hostPort}/dist/${viteConfig.build.lib.name}.iife.js';
document.body.appendChild(script);
})();
`;
const code = `${tampermonkeyConfig}\n${codeContent}`;
prettier.format(code, { parser: 'babel' }).then(formatted => {
if (fs.existsSync(path.resolve(__dirname, codeFilePath))) {
fs.rm(path.resolve(__dirname, codeFilePath), () => {
fs.writeFileSync(path.resolve(__dirname, codeFilePath), formatted);
});
} else {
fs.writeFileSync(path.resolve(__dirname, codeFilePath), formatted);
}
});7. Package Scripts
Development script:
"dev": "node script/gen-tampermonkey.js && nodemon"Build script (compiles Less, runs TypeScript, builds with Vite, and adds the Tampermonkey header):
"dev:build": "node script/gen-style-string.js && tsc && vite build && node script/gen-script-header-comment.js"After development, replace the manually pasted script in Tampermonkey with the generated bundle.
8. Additional Notes
Vite starts a local dev server; the script commands must be executed sequentially (using && ) so that the server starts after the build steps. While this means the server restarts on each change, it ensures the latest bundle is fetched by the Tampermonkey script.
Finding a smarter way to avoid server restarts while still delivering fresh resources is left as an open challenge.
Rare Earth Juejin Tech Community
Juejin, a tech community that helps developers grow.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.