App Architecture with Vue
Kyle Coberly
www.kylecoberly.com
Kyle Coberly
- Educator
- Business Dork
- Software Developer
The Goal:
Make you want to try this pattern
Context Diagram
Application
Inputs
Outputs
Component
Component
Component
The Problem
Application
Inputs
Outputs
Component
Component
Component
The Problem
Well-architected applications are maintainable
The primary driver of maintainability is testability
The primary driver of testability is purity
Application Architecture
Where does the complexity go?
Model-View-Component
Model-View-Component
- Components
- Encapsulate related presentation & behavior
- Prefer emitters over closures
- Use services for application-wide state
- Views
- Communicate with the store through views
- Routes represent a saved application state
- Nested views, nested routes
- Models
- Organize the store with modules
- Normalize data operations with models
- Isolate network requests with adapters & serializers
Components
Encapsulation
Components
Component
Component
Component
Component
Components - Encapsulation
<header>
<h1><a href="/"><img src="" alt="Logo" /></a><h1>
<nav>
<ul>
<li><router-link :to="{name: 'products'}">Products</router-link></li>
<li><router-link :to="{name: 'team'}">Team</router-link></li>
</ul>
</nav>
</header>
<main>
<h2>Our Products</h2>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing</p>
<some-widget />
<another-widget>
<some-nested-widget />
</another-widget>
</main>
<footer>
<nav>
<ul>
<li><router-link :to="{name: 'products'}">Products</router-link></li>
<li><router-link :to="{name: 'team'}">Team</router-link></li>
<li><router-link :to="{name: 'about'}">About</router-link></li>
<li><router-link :to="{name: 'investors'}">Investors</router-link></li>
<li><router-link :to="{name: 'careers'}">Careers</router-link></li>
</ul>
</nav>
<small>© 2008 BarkWire</small>
</footer>
Components - Encapsulation
<application-header />
<router-view />
<application-footer />
<header>
<h1><a href="/"><img src="" alt="Logo" /></a><h1>
<nav>
<ul>
<li><router-link :to="{name: 'products'}">Products</router-link></li>
<li><router-link :to="{name: 'team'}">Team</router-link></li>
</ul>
</nav>
</header>
What's the API for the component?
Emitters vs. Closures
Components
Parent
Child
State
Events
Parent
Child
State
Functions
Components - Emitters vs. Closures
<template>
<counter
:count="count"
@increment="increment"
/>
</template>
<script>
export default {
components: {
counter
},
data(){
return {
count: 1
};
},
methods: {
increment(){
this.count++;
}
}
};
</script>
<template>
<p>{{count}}</p>
<button @click="@emit('increment')"></button>
</template>
<script>
export default {
props: {
count: Number
}
};
</script>
Emitters
Components - Emitters vs. Closures
describe("Outer Component", function(){
describe("#increment", () => {
it("increments", () => {
const component = mount(OuterComponent)
assert.equal(component.vm.count, 1)
component.vm.increment()
assert.equal(component.vm.count, 2)
})
});
});
Testing Emitters
describe("Counter", function(){
describe("render", () => {
it("shows the current count", () => {
const component = mount(Counter, {
propsData: {count: 2}
});
assert.equal(component.text(), "2")
});
});
describe("#increment", () => {
it("increments", () => {
const component = mount(Counter);
component.find("button").trigger("click");
assert.ok(component.emitted().increment);
});
});
});
Components - Emitters vs. Closures
<template>
<counter
:count="count"
:increment="increment"
/>
</template>
<script>
export default {
components: {
counter
},
data(){
return {
count: 1
};
},
methods: {
increment(){
this.count++;
}
}
};
</script>
<template>
<p>{{count}}</p>
<button @click="increment"></button>
</template>
<script>
export default {
props: {
count: Number,
increment: Function
}
};
</script>
Closures
Components - Emitters vs. Closures
describe("Outer Component", function(){
describe("#increment", () => {
it("increments", () => {
const component = mount(OuterComponent)
assert.equal(component.vm.count, 1)
component.vm.increment()
assert.equal(component.vm.count, 2)
})
});
});
Testing Closures
describe("Counter", function(){
describe("#increment", () => {
it("increments", () => {
const incrementStub = () => 2;
const component = mount(Counter, {
propsData: {
count: 1,
increment: incrementStub
}
})
assert.equal(component.text(), "1");
component.find("button").trigger("click");
assert.equal(component.text(), "2");
})
});
});
Components - Emitters vs. Closures
Emitting results in cleaner tests
it("increments", () => {
const component = mount(Counter);
component.find("button").trigger("click");
assert.ok(component.emitted().increment);
});
<counter
:count="count"
@increment="increment"
/>
<counter
:count="count"
:increment="increment"
/>
it("increments", () => {
const incrementStub = () => 2;
const component = mount(Counter, {
propsData: {
count: 1,
increment: incrementStub
}
});
assert.equal(component.text(), "1");
component.find("button").trigger("click");
assert.equal(component.text(), "2");
});
Services
Components
Parent
Child
State
Events
Service
State
Events
Services = Globals
Components - Services
- Use sparingly
- Inject into component tests
- Clearly identify service dependencies
Components - Services
<template>
<form @submit="sendMessage">
<div v-if="loadingService.isLoading">
<loading-spinner />
</div>
</form>
</template>
<script>
export default {
data: () => ({isLoading: this.$store.services.loading.isLoading});
};
</script>
<template>
<button @click="toggleMode">Toggle Mode</button>
</template>
<script>
export default {
methods: {
toggleMode(){
this.$store.services.mode.dispatch("toggleMode");
}
}
};
</script>
Consuming Services - Examples
Components - Services
import Vue from "vue";
import Vuex from "vuex";
import loadingService from "../../src/services/loading";
Vue.use(Vuex);
describe("Form", function(){
describe("loading spinner", () => {
beforeEach(){
this.store = new Vuex.Store();
this.store.registerModule(["services", "loading"], loadingService);
this.component = mount(Form, {store: this.store});
}
it("doesn't display when data isn't loading", () => {
this.component.$store.services.loading.state.isLoading = false;
assert.ok(this.component.find(".loading-spinner").exists());
});
it("does display when data is loading", () => {
this.component.$store.services.loading.state.isLoading = true;
assert.ok(this.component.find(".loading-spinner").exists());
});
});
});
Testing a component with a service
Views
Nested Views, Nested Routes
Views
App.vue
DogList.vue
DogDetail.vue
Views - Nested Views, Nested Routes
Barkwire
- Bixby
- Mesa
- Panzer
- Iago
Panzer is a Big Galoot!
Copyright 2018, Barkwire PBC
NESTED
Views - Nested Views, Nested Routes
Barkwire
Panzer is a Big Galoot!
Copyright 2018, Barkwire PBC
FLAT
Dogs > Panzer
Barkwire
Copyright 2018, Barkwire PBC
Dogs
- Bixby
- Mesa
- Panzer
- Iago
Views - Nested Views, Nested Routes
const router = new VueRouter({
routes: [{
path: "/dogs",
component: DogsList,
children: [{
path: "",
component: DogsListIndex
},{
path: ":id",
component: DogDetail
}
]
}
]
});
const router = new VueRouter({
routes: [{
path: "/dogs",
component: DogsList,
},{
path: "/dogs/:id",
component: DogDetail
}
]
});
https://barkwire.com/dogs
https://barkwire.com/dogs/1
Views - Nested Views, Nested Routes
App.vue
DogList.vue
DogDetail.vue
https://barkwire.com/dogs/1
All routes
All /dogs routes
All /dogs/:id routes
URL = State
Views
Barkwire
Panzer is a Big Galoot!
Copyright 2018, Barkwire PBC
Dogs > Panzer
Barkwire
Panzer is a Big Galoot!
Copyright 2018, Barkwire PBC
Dogs > Panzer
https://barkwire.com/dogs/1
https://barkwire.com/dogs/1
Why?
Views - URL = State
- The app should have the same state after refresh
- Necessary for sending links and bookmarking
Data Access
Views
Store
Actions
View
Component
State
Events
State
Why Here?
Views - Data Access
- Since a URL represents a state and maps to a view...
- And since our components should be dumb to increase testability...
- Our views should be responsible for getting and setting application state
Views - Data Access
<template>
<dog-profile
dog="dog"
updateDate="updateDog"
/>
</template>
<script>
export {
created(){
return this.$store.dogs.dispatch("fetch", this.id);
},
computed: {
id(){
return this.$route.params.id;
},
dog(){
return this.$store.dogs.getters.read(this.id);
}
},
methods: {
updateDog(dog){
return this.$store.dogs.dispatch("update", dog);
}
}
};
</script>
Views - Data Access
Advantages
- Don't need to be directly tested
- Dumb down your components
- Creates a "seam" for debugging
Models
Modules
Models
Store
Data Module
Services Module
Service Module
Service Module
Actions
State
CRUD
Model
Data Module
Data Module
Rules
- The store coordinates modules
- Namespace all the services together
- Only dispatch actions to modules
- Defer implementation details to models
Models - Modules
Substores
Models - Modules
export default new Vuex.Store({
state: {
dogs: []
},
mutations: {
setDogs(state, dogs){
return state.dogs = dogs;
},
updateDog(state, updatedDog){
let dog = state.dogs.find(dog => updatedDog.id);
dog = updatedDog;
}
},
actions: {
async fetchDogs({commit}, dogs){
const dogs = await Dog.fetchAll();
return commit("setDogs", dogs);
},
async updateDog({commit}, dog){
const dog = await dog.save();
return commit("updateDog", dog);
}
}
});
Data Models
Models
Model
Store
Module
CRUD
Model
Why?
- Separate your use of data from where/how it's stored
- DRY your CRUD code
- Normalize your data
Models - Data Models
Models - Data Models
class Dog {
constructor(data){
this.id = +data.id;
this.properties = ["name", "breed", "ownerId"]
this.modelName = "dog";
this.data = this.normalize(data);
}
normalize(data){
return this.properties.reduce((modelData, property) => {
modelData.push(data[property]);
return data;
}, []);
}
fetch(){}
static fetchAll(){}
destroy(){}
save(){}
}
Building a model
Models - Data Models
class Model {
constructor(data){
this.id = +data.id;
}
normalize(data){
return this.properties.reduce((modelData, property) => {
modelData.push(data[property]);
return data;
}, []);
}
fetch(){}
static fetchAll(){}
destroy(){}
save(){}
}
class Dog extends Model {
constructor(data){
super(data);
this.properties = ["name", "breed", "ownerId"]
this.modelName = "dog";
this.data = this.normalize(data);
}
}
Extracting Common Behavior
Models - Data Models
<template>
<form @submit="addDog">
<label>Name</label>
<input name="name" :value="name" />
<input type="submit" value="Add Dog" />
</form>
</template>
<script>
import Dog from "../models/dog";
export default {
methods: {
addDog(){
this.$emit("addDog", new Dog({
name: this.name
}));
}
}
};
</script>
Using Models to Create
Models - Data Models
import Dog from "../models/dog";
export default {
state: {
dogs: []
},
mutations: {
setDogs(state, dogs){
return state.dogs = dogs;
}
}
actions: {
async fetchAll({commit}){
const dogs = Dog.fetchAll();
return commit.setDogs(dogs);
}
}
}
Using Models to Retrieve
Models - Data Models
<template>
<div>
<h2>Dog Name</h2>
<p>{{dog.data.name}}</p>
</div>
</template>
<script>
export default {
props: {
dog: Object
}
};
</script>
Using Model Data
Adapters & Serializers
Models
Adapter
Serializer
Deserializer
Model
API
The Adapter Pattern
Models - Adapters & Serializers
Adapter
Serializer
Deserializer
Model to Save
Saved Model
Internal Format
External Format
External Format
Internal Format
POST Request
POST Response
Serializers
export default {
serialize(model){
return JSON.stringify(model);
},
deserialize(data){
return typeof data === "string"
? JSON.parse(data)
: data;
}
};
Models - Adapters & Serializers
Adapters
import Serializer from "./serializer";
class Adapter {
constructor(modelName){
this.serializer = Serializer;
this.modelName = modelName;
this.baseUrl = process.env.BASE_URL;
this.buildUrl.bind(this);
this.serialize.bind(this);
this.deserialize.bind(this);
}
buildUrl(){
return `${this.baseUrl}/${pluralize(this.modelName)}`;
}
serialize(model){
return this.serializer.serialize(model);
}
deserialize(data){
return this.serializer.deserialize(data);
}
save(model){
return fetch(this.buildUrl(), {
method: "POST",
"Content-Type": "application/json",
body: this.serialize(model)
}).then(response => response.json())
.then(response => this.serializer.deserialize(response));
}
}
Models - Adapters & Serializers
Using an adapter in a model
import Adapter from "./adapter";
class Dog extends Model {
constructor(data){
super(data);
this.adapter = new Adapter("dog");
}
save(){
return this.adapter.save()
}
}
Models - Adapters & Serializers
Keeps your modules clean!
{
actions: {
async save({commit}, dog){
const dog = new Dog(dog);
const savedDog = await dog.save();
return commit("save", savedDog);
}
}
}
Models - Adapters & Serializers
Review
Model-View-Component
- Components
- Encapsulate related presentation & behavior
- Prefer emitters over closures
- Use services for application-wide state
- Views
- Communicate with the store through views
- Routes represent a saved application state
- Nested views, nested routes
- Models
- Organize the store with modules
- Normalize data operations with models
- Isolate network requests with adapters & serializers
Bonus!
Testing
- Unit Test:
- Serializers
- Adapters (stub network)
- Models
- Integration Test:
- Component methods, rendering & emitting
- Services
- E2E Test:
- Happy/Sad Paths for features (stub network)
- Happy/Sad Paths for features (real network)
Styling
- Style components to be relatively "unthemed"
- No thematic colors
- Sizes should be highly responsive
- Theme them in the view
Increment!
How much?
42
Increment!
How much?
42
Questions?
kylecoberly.com
App Architecture w/ Vue
By Kyle Coberly
App Architecture w/ Vue
- 1,487