Using Vue.js in EMPS Projects
EMPS supports both Vue 2 and Vue 3 projects. The approach differs somewhat
from standard Vue usage — there is no build step, no Vite, no Webpack, no .vue
single-file components in the traditional sense. Everything is assembled in the
browser at runtime from plain JS files served via MJS.
If you are an experienced Vue developer, there are only a handful of differences to learn. Read this page once and you will be able to write Vue code correctly for EMPS projects from the start.
No Build Step
There is no compile stage. No npm run build, no bundled output. The browser
loads JS files directly and assembles the Vue application from mixins and
components at page load time.
What this means in practice:
- No Single-File Components (
.vuefiles in EMPS are HTML-only templates, not SFCs) - No
import/exportmodule syntax in JS files - No Composition API (Options API only)
- No TypeScript
Everything else you know about Vue (reactivity, computed, watchers, directives,
v-if, v-for, @click, etc.) works exactly as documented.
The Mini-SPA Model
Each page of an EMPS website is its own small Vue application — a mini-SPA.
Pages like /projects/, /tasks/, /reports/ each have their own Vue app.
They can share components and mixins (which are cached by the browser), but
there is no single compiled app that covers the entire website.
When the user navigates within a page (e.g. from a list to an item view),
the URL changes via window.history.pushState without a full page reload.
The mini-SPA reads the new URL and updates what is displayed.
The mainapp Pattern
Most EMPS projects have a central Vue application entry point called mainapp,
typically located at modules/comp/mainapp.js. It:
- Collects all mixins pushed into
window.main_app_mixins - Collects all components registered before it runs
- Creates and mounts the Vue app on
<div id="mainapp">
Each page's own JS file pushes a mixin into main_app_mixins:
emps_scripts.push(function () {
window.main_app_mixins.push({
mixins: [table_mixin, navigation_mixin],
data: function () {
return { lst: [], row: {}, pages: {} };
},
mounted: function () {
$(".app-loading").hide();
this.navigate(window.location.href);
},
methods: {
// page-specific logic
}
});
});
The code is wrapped in emps_scripts.push(function() { ... }) so it runs after
the page has fully loaded. emps_scripts is a global array defined in the page
<head> by EMPS and is processed by
emps_scripts.js.
To enable mainapp on a page, the PHP controller sets:
$emps->page_property("vuejs", 1);
$emps->page_property("mainapp", true);
Registering Components
Because there is no build step, components must be registered before the Vue app is created. EMPS provides two helper functions for this:
EMPS.vue_component_direct(name, options) — Vue 2 & 3
Use this for components with a small, inline HTML template (Option A):
emps_scripts.push(function() {
EMPS.vue_component_direct('icon', {
template: '#icon-component-template',
props: ['id', 'class'],
methods: {
get_class: function() {
return "icon " + (this['class'] || "");
}
}
});
});
The template is a <script type="text/x-template"> tag in the page HTML,
placed outside the <div id="mainapp"> div:
{{if !$comp_icon_included}}
{{var comp_icon_included=true}}
<script type="text/x-template" id="icon-component-template">
<svg :class="get_class()" preserveAspectRatio="xMinYMin">
<use :xlink:href="'/css/symbols.svg#' + id"></use>
</svg>
</script>
{{script src="/mjs/comp-icon/icon.js" defer=1}}
{{/if}}
EMPS.vue_component(name, templateUrl, options) — Option B (external template)
For large templates that should not be inlined into every page response, load
the template from a separate .vue file:
EMPS.vue_component('order-row', '/mjs/orders/order-row.vue', {
props: ['row'],
methods: { ... }
});
EMPS will fetch the template via AJAX, inject it into an empty
<script type="text/x-template"> tag, then register the component.
Reminder:
.vuefiles in EMPS contain only HTML markup — no<script>or<style>sections. They are not Single-File Components.
Keeping Scripts Outside mainapp
Vue mounts on <div id="mainapp"> and compiles everything inside it as a
template. Any <script> tags, <script type="text/x-template"> blocks, or
{{script}} includes placed inside that div will cause Vue warnings or
silent failures.
The challenge: module .nn.htm files are rendered inside the page body, which
is also where mainapp lives. The solution is a Smarty capture buffer
that collects all the Vue-related tags from module templates and flushes them
in the site shell, outside mainapp.
1. Initialise the buffer in predisplay.php
Create (or edit) modules/_common/config/project/predisplay.php:
$smarty->assign("vue_out", []);
This runs before any page template is rendered, so $vue_out is always an
empty array at the start of every request.
2. Capture Vue tags in each module template
In your .nn.htm file, wrap all {{script}} includes and component template
tags in a {{capture append="vue_out"}} block:
{{capture append="vue_out"}}
{{script src="/mjs/comp-mixins/common.js" defer=1}}
{{script src="/mjs/comp-table/table.js" defer=1}}
{{script src="/mjs/comp-navigation/navigation.js" defer=1}}
{{script src="/mjs/manage-items/items.js" defer=1}}
{{include file="db:_comp/modal"}}
{{/capture}}
<div>
<!-- mainapp template content here -->
</div>
The append modifier pushes each captured block as a new item onto the
$vue_out array, so multiple modules on the same page don't overwrite each
other's output.
3. Flush the buffer in the site shell
In any site shell template that is rendered outside <div id="mainapp"> —
typically templates/site/foot.nn.htm or templates/site/commonfoot.nn.htm —
add:
{{foreach from=$vue_out item="text"}}
{{$text}}
{{/foreach}}
Now all scripts and component templates are emitted after mainapp closes,
exactly where Vue expects them to be absent from its compilation scope.
Mixins
Prefer mixins over components for splitting large JS files or sharing methods across pages. A mixin is a plain JS object with any Options API keys:
// modules/comp/mixins/common.js
emps_scripts.push(function() {
window.common_mixin = {
methods: {
enum_val: function(ecode, code) { ... },
money: function(v) { ... }
}
};
});
Commonly used shared mixins from EMPS:
| URL | Contents |
|---|---|
/mjs/comp-mixins/common.js |
enum_val, formatting helpers |
/mjs/comp-table/table.js |
List/table management (table_mixin) |
/mjs/comp-navigation/navigation.js |
URL-based navigation (navigation_mixin) |
Smarty + Vue Templates
EMPS uses the same {{ }} delimiters for both Smarty and Vue. The rule is simple:
{{noSpace}}— processed by Smarty on the server{{ space }}— passed through to Vue in the browser
{{* Smarty sets a variable *}}
{{assign var="title" value="Orders"}}
<h1>{{$title}}</h1> {{* rendered by Smarty *}}
<span>{{ row.name }}</span> {{* rendered by Vue *}}
<button @click="save_row(row)">Save</button>
See the full guide: Templates: Smarty+Vue
Loading Enums in Vue
Enum values (defined in modules/_common/config/enum.nn.txt) can be loaded
into a Vue component's enums data object and then displayed with enum_val():
mounted: function () {
EMPS.vue_load_enums(this, "order_status,payment_type");
}
<span>{{ enum_val('order_status', row.status) }}</span>
See the full guide: Enum Values
Event Bus (vuev)
Vue 3 removed the built-in event bus ($on, $emit on the app instance). EMPS
re-implements it as a global vuev object:
// Listen
vuev.$on('order_saved', function(data) {
console.log('Order saved:', data.id);
});
// Emit from anywhere
vuev.$emit('order_saved', { id: 42 });
// Stop listening
vuev.$off('order_saved');
AJAX with axios
EMPS uses axios for all AJAX requests. The PHP controller handles JSON requests
on the same URL as the page:
// JS: load list
axios.get("./?load_list=1&start=" + this.start).then(response => {
let data = response.data;
if (data.code == 'OK') {
this.lst = data.lst;
this.pages = data.pages;
}
});
// JS: save row
axios.post("./?", { post_save: true, payload: this.row }).then(response => {
if (response.data.code == 'OK') {
toastr.success("Saved!");
}
});
// PHP: same URL handles JSON requests
if ($_GET['load_list'] ?? false) {
$lst = [...];
$emps->json_ok(['lst' => $lst, 'pages' => $pages]); exit;
}
if ($_POST['post_save'] ?? false) {
// save $_POST['payload']
$emps->json_ok(); exit;
}
POST data sent as a JSON body (default for axios) is automatically merged into
$_POST by EMPS's pre_init().
Practical Rules
- Don't create a component for everything. Use components only when you need
multiple independent instances of the same UI element, recursive rendering
(e.g. a tree), or a piece of UI reused across many pages. For everything else,
use mixins and Smarty
{{include}}. - Keep
<script>and component template tags outside<div id="mainapp">. Vue will try to compile the content of its mount element. - Use
{{include file="db:_comp/..."}}in Smarty templates to include component initialization snippets — this pattern avoids loading a component twice. - Check
mainappvsno_mainappper project. Some projects enable mainapp withpage_property("mainapp", true), others have it on by default and disable it withpage_property("no_mainapp", true). Check the project'stemplates/site/headscripts.nn.htmto see which convention is used.