0 / 0
Skip to content

From IIFE to Rollup: Modernizing a Vue Visitor Counter

The Journey

In this post, I’ll share my experience of transforming a self-contained IIFE (Immediately Invoked Function Expression) script into a modern, maintainable Vue component bundled with Rollup. This journey demonstrates how to evolve a simple script into a more structured, maintainable codebase while preserving its core functionality.

The Original Script

The original script (visitorData.iife.js.original) was a self-contained IIFE that:

  1. Dynamically loaded dependencies (Vue, Socket.IO, FingerprintJS, Font Awesome)
  2. Injected CSS styles directly into the document
  3. Created and mounted a Vue component
  4. Handled real-time visitor tracking
javascript
;(async function () {
  // Reference to the current script tag
  const scriptTag = document.currentScript

  // Create a container for the Vue component
  const container = document.createElement('div')
  scriptTag.replaceWith(container)

  // Function to dynamically load a script and return a promise
  const loadScript = (url, globalVar) =>
    new Promise((resolve) => {
      if (window[globalVar]) return resolve(window[globalVar])
      const script = document.createElement('script')
      script.src = url
      script.onload = () => resolve(window[globalVar])
      document.head.appendChild(script)
    })

  // Function to load script without waiting for global variable
  const loadScriptSimple = (url) =>
    new Promise((resolve) => {
      const script = document.createElement('script')
      script.src = url
      script.onload = () => resolve()
      document.head.appendChild(script)
    })

  // Load Font Awesome and Socket.IO
  await Promise.all([
    loadScriptSimple(
      'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/js/all.min.js'
    ),
    loadScript('https://cdn.socket.io/4.8.1/socket.io.min.js', 'io'),
  ])

  // Load other dependencies using import
  const [Vue, fpPromise] = await Promise.all([
    import('https://unpkg.com/vue@3/dist/vue.esm-browser.prod.js'),
    import('/vendor/fingerprintjs.v4-6-1.js').then((FingerprintJS) =>
      FingerprintJS.load()
    ),
  ])

  // Inject styles dynamically
  const style = document.createElement('style')
  style.textContent = `
    :root {
      --vd-c-bg: #000;
      --vd-c-divider: #fff;
      --vd-c-brand: #fff;
    }

    .visitor-data {
      position: fixed;
      bottom: 1rem;
      right: 1rem;
      background: var(--vd-c-bg);
      border: 1px solid var(--vd-c-divider);
      border-radius: 4px;
      padding: 0.5rem;
      display: flex;
      align-items: center;
      gap: 0.5rem;
      z-index: 1000;
      font-size: 0.875rem;
      color: var(--vd-c-brand);
    }

    .visitor-data i {
      color: inherit;
    }
  `
  document.head.appendChild(style)

  // Create the Vue component
  const { createApp, ref, onMounted, onBeforeUnmount, reactive } = Vue
  const VisitorCounter = {
    template: `
      <footer class="visitor-data" title="Active Visitors">
        <div>{{ connections }} / {{ uniqueVisitors }}</div>
        <i class="fas fa-users" />
      </footer>
    `,
    setup() {
      // Initialize Socket.IO
      const socket = io({
        reconnection: true,
        reconnectionAttempts: 5,
        reconnectionDelay: 1000,
        reconnectionDelayMax: 5000,
        timeout: 20000,
        autoConnect: true,
        forceNew: true,
      })

      const stats = reactive({
        connections: 0,
        uniqueVisitors: 0,
      })

      onMounted(async () => {
        // Initialize FingerprintJS and get visitor ID
        const fp = await fpPromise
        const { visitorId } = await fp.get()

        const getVisitorData = () => ({
          ...(visitorId && { visitorId }),
          referrer: document.referrer,
        })

        // Listen for stats updates
        socket.on('stats', (data) => {
          Object.assign(stats, data)
        })

        socket.on('connect', (attemptNumber) => {
          socket.emit('getStats')
        })
        socket.on('reconnect', (attemptNumber) => {
          socket.emit('getStats')
          socket.emit('visitor-data', getVisitorData())
        })

        // Send visitor data to server
        socket.emit('visitor-data', getVisitorData())
      })

      onBeforeUnmount(() => {
        if (socket) socket.disconnect()
      })

      return stats
    },
  }

  // Mount the Vue app
  createApp(VisitorCounter).mount(container)
})()

The Challenges

While the original script worked well, it had several limitations:

  1. Dependency Management: Manually loading dependencies via script tags and dynamic imports was error-prone
  2. Code Organization: Everything was in a single file, making it hard to maintain
  3. Build Process: No minification, tree-shaking, or other optimizations
  4. Development Experience: No hot-reloading or other development tools
  5. CSS Handling: Styles were injected via JavaScript, making them hard to maintain

The Solution: Vue + Rollup

I decided to modernize the codebase using Vue’s Single File Component (SFC) format and Rollup for bundling. Here’s how I approached it:

1. Creating a Vue Single File Component

I converted the inline Vue component into a proper .vue file with separate template, script, and style sections:

vue
<template>
  <footer class="hi-visitor-data" title="Active Visitors">
    <div>{{ connections }} / {{ uniqueVisitors }}</div>
    <i class="fas fa-users" />
  </footer>
</template>

<script>
import { onMounted, onBeforeUnmount, reactive } from 'vue'
import { io } from 'socket.io-client'
import FingerprintJS from '@fingerprintjs/fingerprintjs'

// We’ll need a wrapper to create and mount the app
export const mountVisitorCounter = async () => {
  const scriptTag = document.currentScript
  const container = document.createElement('div')
  scriptTag.replaceWith(container)

  // Load Font Awesome
  const script = document.createElement('script')
  script.src =
    'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/js/all.min.js'
  document.head.appendChild(script)

  return container
}

export default {
  name: 'VisitorCounter',
  setup() {
    // Initialize Socket.IO
    const socket = io({
      reconnection: true,
      reconnectionAttempts: 5,
      reconnectionDelay: 1000,
      reconnectionDelayMax: 5000,
      timeout: 20000,
      autoConnect: true,
      forceNew: true,
    })

    const stats = reactive({
      connections: 0,
      uniqueVisitors: 0,
    })

    onMounted(async () => {
      // Initialize FingerprintJS and get visitor ID
      const fp = await FingerprintJS.load()
      const { visitorId } = await fp.get()

      const getVisitorData = () => ({
        ...(visitorId && { visitorId }),
        referrer: document.referrer,
      })

      // Listen for stats updates
      socket.on('stats', (data) => {
        Object.assign(stats, data)
      })

      socket.on('connect', () => {
        socket.emit('getStats')
      })

      socket.on('reconnect', () => {
        socket.emit('getStats')
        socket.emit('visitor-data', getVisitorData())
      })

      // Send visitor data to server
      socket.emit('visitor-data', getVisitorData())
    })

    onBeforeUnmount(() => {
      if (socket) socket.disconnect()
    })

    return stats
  },
}
</script>

<style>
:root {
  --vd-c-bg: #000;
  --vd-c-divider: #fff;
  --vd-c-brand: #fff;
}

.hi-visitor-data {
  position: fixed;
  bottom: 1rem;
  right: 1rem;
  background-color: var(--vd-c-bg);
  border: 1px solid var(--vd-c-divider);
  border-radius: 4px;
  padding: 0.5rem;
  display: flex;
  align-items: center;
  gap: 0.5rem;
  z-index: 1000;
  font-size: 0.875rem;
  color: var(--vd-c-brand);
}

.hi-visitor-data i {
  color: inherit;
}
</style>

2. Creating an Entry Point

I created a simple entry point file (widget.js) that imports and mounts the Vue component:

javascript
import { createApp } from 'vue'
import VisitorCounter, { mountVisitorCounter } from './visitorData.iife.vue'
mountVisitorCounter().then(createApp(VisitorCounter).mount)

3. Setting Up Rollup

I configured Rollup to bundle the Vue component into an IIFE:

javascript
import resolve from '@rollup/plugin-node-resolve'
import commonjs from '@rollup/plugin-commonjs'
import terser from '@rollup/plugin-terser'
import vuePlugin from 'rollup-plugin-vue'
import replace from '@rollup/plugin-replace'
import postcss from 'rollup-plugin-postcss'

export default {
  input: 'src/widget.js',
  output: {
    file: 'dist/visitorData.iife.min.js',
    format: 'iife',
    sourcemap: true,
  },
  plugins: [
    replace({
      'process.env.NODE_ENV': JSON.stringify('production'),
      preventAssignment: true,
    }),
    vuePlugin({
      css: true,
      template: {
        isProduction: true,
      },
    }),
    postcss({
      extract: false,
      inject: true,
      minimize: true,
      sourceMap: true,
    }),
    resolve({
      browser: true,
      preferBuiltins: false,
      extensions: ['.js', '.vue'],
    }),
    commonjs(),
    terser(),
  ],
}

4. Managing Dependencies with pnpm

I used pnpm to manage dependencies:

json
{
  "name": "visitor-data-iife",
  "version": "1.0.0",
  "type": "module",
  "packageManager": "[email protected]",
  "description": "Visitor data collection with IIFE",
  "main": "dist/visitorData.iife.min.js",
  "scripts": {
    "build": "rollup -c",
    "dev": "rollup -c -w",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "dependencies": {
    "@fingerprintjs/fingerprintjs": "^4.6.1",
    "socket.io-client": "^4.8.1",
    "vue": "^3.5.13"
  },
  "devDependencies": {
    "@rollup/plugin-commonjs": "^28.0.3",
    "@rollup/plugin-node-resolve": "^16.0.1",
    "@rollup/plugin-replace": "^6.0.2",
    "@rollup/plugin-terser": "^0.4.4",
    "rollup": "^4.39.0",
    "rollup-plugin-postcss": "^4.0.2",
    "rollup-plugin-vue": "^6.0.0"
  }
}

5. CSS Namespacing with the hi- Prefix

One important improvement in the modernized version is the use of the hi- prefix for CSS classes. In the original script, I used a generic class name .visitor-data, which could potentially conflict with other styles on the page. In the Vue component, I renamed it to .hi-visitor-data.

This naming convention follows the BEM (Block Element Modifier) methodology and provides several benefits:

  1. Namespace Isolation: The hi- prefix (which stands for "Harianto’s Interface") creates a namespace that prevents style conflicts with other components or libraries on the page
  2. Component Identification: It makes it immediately clear which styles belong to this specific component
  3. Maintainability: When debugging, it’s easier to identify which component is responsible for specific styles
  4. Scalability: As the project grows, this naming convention helps maintain a clear separation between different components

This is particularly important for an IIFE that will be embedded in various websites, as it ensures our styles won’t be affected by or affect the host site’s styles.

The Benefits

The modernized approach offers several advantages:

  1. Better Code Organization: Separating template, script, and styles makes the code more maintainable
  2. Proper Dependency Management: Using npm/pnpm for dependencies instead of manual script loading
  3. Optimized Build: Minification, tree-shaking, and other optimizations via Rollup
  4. Improved Development Experience: Watch mode for faster development
  5. Better CSS Handling: CSS is processed and optimized by PostCSS
  6. Source Maps: For easier debugging
  7. CSS Namespacing: The hi- prefix prevents style conflicts with the host site

The Result

The final result is a single IIFE script that can be included in any webpage with a single script tag:

html
<script src="https://unpkg.com/visitor-data-iife/dist/visitorData.iife.min.js"></script>

The visitor counter will automatically appear in the bottom-right corner of the page, just like before, but now it’s built with modern tools and practices.

Conclusion

This journey demonstrates how to evolve a simple script into a more structured, maintainable codebase while preserving its core functionality. By leveraging Vue’s Single File Component format and Rollup’s bundling capabilities, we’ve created a more robust solution that’s easier to maintain and extend.

The key takeaway is that modernizing your codebase doesn’t have to mean changing how your users interact with your code. We’ve maintained the simple one-script integration while significantly improving the development experience and code quality behind the scenes.