Vue.js Component System Development for 1C-Bitrix
A single Vue component on a site is an integration. A component system is an architecture. The difference is fundamental: in a system, components share common state, a common design token, a unified initialization mechanism, and a standard way to interact with the Bitrix backend. Without a systematic approach, within six months the site ends up with three versions of the cart, two different approaches to AJAX, and duplicated code in every component.
What the Component System Includes
A typical Vue component system for a Bitrix store:
- Core: app configuration, Pinia stores, API layer, utilities
- UI components: buttons, inputs, modals, toasts — no dependency on business logic
- Business components: cart, wishlist, comparison, reviews, filter — use stores and API
- Mount points: places in the PHP template where Vue components are mounted
File Structure
/local/js/vue/
├── app.ts # initialization and mounting
├── api/
│ ├── client.ts # base HTTP client
│ ├── cart.ts
│ ├── catalog.ts
│ └── wishlist.ts
├── stores/
│ ├── cartStore.ts
│ ├── wishlistStore.ts
│ ├── compareStore.ts
│ └── userStore.ts
├── components/
│ ├── ui/ # design system
│ │ ├── Button.vue
│ │ ├── Modal.vue
│ │ ├── Toast.vue
│ │ ├── Spinner.vue
│ │ └── Badge.vue
│ ├── cart/
│ │ ├── CartButton.vue # cart button in the header
│ │ ├── CartDrawer.vue # slide-out cart panel
│ │ └── CartItem.vue
│ ├── catalog/
│ │ ├── AddToCartBtn.vue
│ │ ├── WishlistBtn.vue
│ │ └── CompareBtn.vue
│ └── product/
│ ├── Reviews.vue
│ └── SizeAdvisor.vue
└── types/
├── cart.ts
├── product.ts
└── user.ts
Initialization Mechanism
The problem with multiple Vue components on a single page: you cannot create a separate app (createApp) for each one — they will not share Pinia stores, and the cart state in the header will differ from the state in the popup.
The correct approach: one application, multiple mount points.
// app.ts
import { createApp, defineAsyncComponent } from 'vue'
import { createPinia } from 'pinia'
// Register components for mounting via data attributes
const componentRegistry: Record<string, any> = {
'cart-button': defineAsyncComponent(() => import('./components/cart/CartButton.vue')),
'add-to-cart': defineAsyncComponent(() => import('./components/catalog/AddToCartBtn.vue')),
'wishlist-btn': defineAsyncComponent(() => import('./components/catalog/WishlistBtn.vue')),
'compare-btn': defineAsyncComponent(() => import('./components/catalog/CompareBtn.vue')),
'reviews': defineAsyncComponent(() => import('./components/product/Reviews.vue')),
'size-advisor': defineAsyncComponent(() => import('./components/product/SizeAdvisor.vue')),
}
// Create one app with Pinia
const pinia = createPinia()
// Mount components into each element with data-vue-component
document.querySelectorAll('[data-vue-component]').forEach((el) => {
const name = el.getAttribute('data-vue-component')!
const Component = componentRegistry[name]
if (!Component) return
// Parse props from data attributes
const props: Record<string, any> = {}
for (const attr of el.attributes) {
if (attr.name.startsWith('data-prop-')) {
const propName = attr.name.replace('data-prop-', '').replace(/-./g, m => m[1].toUpperCase())
props[propName] = JSON.parse(attr.value)
}
}
const app = createApp(Component, props)
app.use(pinia) // One Pinia instance — shared state
app.mount(el)
})
In the PHP template:
<!-- Header: cart button -->
<div data-vue-component="cart-button"></div>
<!-- Product card: add buttons -->
<div data-vue-component="add-to-cart"
data-prop-product-id="<?= $product['ID'] ?>"
data-prop-price="<?= $product['PRICE'] ?>"></div>
<div data-vue-component="wishlist-btn"
data-prop-product-id="<?= $product['ID'] ?>"></div>
Key point: one pinia instance is passed to all apps. This means cartStore in the header and cartStore on the product card are the same store — state is synchronized.
API Layer: Unified HTTP Client
// api/client.ts
const CSRF_TOKEN = (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement)?.content
async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
const res = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
'X-Bitrix-Csrf-Token': CSRF_TOKEN,
...options.headers,
},
})
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`)
const data = await res.json()
if (data.errors?.length) throw new Error(data.errors[0].message)
return data
}
export const apiGet = <T>(url: string) => request<T>(url)
export const apiPost = <T>(url: string, body: unknown) =>
request<T>(url, { method: 'POST', body: JSON.stringify(body) })
All components use apiGet / apiPost — a single point for adding authorization headers, error logging, and interceptors.
Design System: Tokens and UI Components
Colors, fonts, spacing — via CSS variables that match the variables in the Bitrix PHP template:
/* Defined in the Bitrix template, used by Vue components */
:root {
--color-primary: #0052cc;
--color-success: #00875a;
--color-danger: #de350b;
--spacing-sm: 8px;
--spacing-md: 16px;
--border-radius: 4px;
}
The Vue Button.vue component uses these variables — visually compatible with the rest of the site.
Lazy Loading and Code Splitting
defineAsyncComponent + Vite automatically splits the code into chunks. CartDrawer.vue is loaded only when the user clicks the cart button — not in the initial bundle. This is critical for performance: the initial page does not load component code that may never be used.
// Load only on first interaction
const CartDrawer = defineAsyncComponent({
loader: () => import('./components/cart/CartDrawer.vue'),
loadingComponent: Spinner,
delay: 200,
})
Testing the System
Unit tests for Pinia stores via Vitest:
// stores/cartStore.test.ts
import { setActivePinia, createPinia } from 'pinia'
import { useCartStore } from './cartStore'
describe('cartStore', () => {
beforeEach(() => setActivePinia(createPinia()))
it('adds a product to the cart', async () => {
const store = useCartStore()
await store.add(123, 1)
expect(store.items).toHaveLength(1)
expect(store.count).toBe(1)
})
})
Component tests via Vue Test Utils + Vitest.
Build and Bitrix Integration
Vite is configured to build into /local/js/vue/dist/. In the Bitrix template it is connected via:
// In the template's header.php
\Bitrix\Main\Page\Asset::getInstance()->addJs('/local/js/vue/dist/app.js', true);
// Or via type=module for native ES modules
?>
<script type="module" src="/local/js/vue/dist/app.js"></script>
Timeline
| Scale | What's included | Duration |
|---|---|---|
| Basic system (3–5 components) | Initialization, Pinia, API layer, UI base | 3–5 weeks |
| Full system | + cart, wishlist, comparison, reviews | 6–10 weeks |
| + Design system, tests, CI | + tokens, Vitest, auto-build | +2–3 weeks |
A component system is an investment in project maintainability. The first component is quick to write, but in a chaotic architecture; the tenth is already painful to duplicate. The right system from the first component saves time on every subsequent one.







