Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Preserving this identity (Vue 3) + tweaks #32

Open
wants to merge 15 commits into
base: feature/vue-3
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,116 @@
# Created by https://www.toptal.com/developers/gitignore/api/yarn,intellij+all
# Edit at https://www.toptal.com/developers/gitignore?templates=yarn,intellij+all

### Intellij+all ###
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839

# User-specific stuff
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/usage.statistics.xml
.idea/**/dictionaries
.idea/**/shelf

# AWS User-specific
.idea/**/aws.xml

# Generated files
.idea/**/contentModel.xml

# Sensitive or high-churn files
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml

# Gradle
.idea/**/gradle.xml
.idea/**/libraries

# Gradle and Maven with auto-import
# When using Gradle or Maven with auto-import, you should exclude module files,
# since they will be recreated, and may cause churn. Uncomment if using
# auto-import.
# .idea/artifacts
# .idea/compiler.xml
# .idea/jarRepositories.xml
# .idea/modules.xml
# .idea/*.iml
# .idea/modules
# *.iml
# *.ipr

# CMake
cmake-build-*/

# Mongo Explorer plugin
.idea/**/mongoSettings.xml

# File-based project format
*.iws

# IntelliJ
out/

# mpeltonen/sbt-idea plugin
.idea_modules/

# JIRA plugin
atlassian-ide-plugin.xml

# Cursive Clojure plugin
.idea/replstate.xml

# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties

# Editor-based Rest Client
.idea/httpRequests

# Android studio 3.1+ serialized cache file
.idea/caches/build_file_checksums.ser

### Intellij+all Patch ###
# Ignores the whole .idea folder and all .iml files
# See https://github.com/joeblau/gitignore.io/issues/186 and https://github.com/joeblau/gitignore.io/issues/360

.idea/

# Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-249601023

*.iml
modules.xml
.idea/misc.xml
*.ipr

# Sonarlint plugin
.idea/sonarlint

### yarn ###
# https://yarnpkg.com/advanced/qa#which-files-should-be-gitignored

.yarn/*
!.yarn/releases
!.yarn/plugins
!.yarn/sdks
!.yarn/versions

# if you are NOT using Zero-installs, then:
# comment the following lines
!.yarn/cache

# and uncomment the following lines
# .pnp.*

# End of https://www.toptal.com/developers/gitignore/api/yarn,intellij+all

/node_modules
/build/.rpt2_cache
/dist
12 changes: 10 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"author": "Dave Stewart",
"license": "MIT",
"main": "dist/vue-class-store.js",
"module": "dist/vue-class-store.esm.ts",
"module": "dist/vue-class-store.esm.js",
"types": "dist/index.d.ts",
"files": [
"dist/*",
Expand All @@ -17,7 +17,7 @@
"scripts": {
"dev": "rollup -c build/rollup.js -w",
"build": "rollup -c build/rollup.js",
"test": "echo \"Error: no test specified\" && exit 1"
"test": "ts-mocha -p tests/tsconfig.json tests/**/*.spec.ts"
},
"peerDependencies": {
"vue": "^3.0.0-beta.14"
Expand All @@ -27,13 +27,21 @@
"@rollup/plugin-buble": "^0.21.3",
"@rollup/plugin-commonjs": "^11.1.0",
"@rollup/plugin-node-resolve": "^7.1.3",
"@types/chai": "^4.2.22",
"@types/chai-spies": "^1.0.3",
"@types/expect": "^24.3.0",
"@types/mocha": "^9.0.0",
"chai": "^4.3.4",
"chai-spies": "^1.0.0",
"mocha": "^9.1.3",
"read-package-json": "^2.1.1",
"rimraf": "^3.0.2",
"rollup": "^2.10.2",
"rollup-plugin-license": "^2.0.1",
"rollup-plugin-typescript2": "^0.27.1",
"rollup-plugin-uglify": "^6.0.4",
"rollup-plugin-vue": "^5.1.7",
"ts-mocha": "^8.0.0",
"tslib": "^2.0.0",
"typescript": "^3.9.2",
"vue": "^3.0.0-beta.14",
Expand Down
188 changes: 117 additions & 71 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,97 +1,143 @@
import { computed, reactive, watch } from 'vue'
import {computed, reactive, watch, WatchOptions} from 'vue'

type C = { new (...args: any[]): {} }

type R = Record<any, any>

function getDescriptors (model: R) {
const prototype = Object.getPrototypeOf(model)
if (prototype === null || prototype === Object.prototype) {
function getDescriptors (model: R): { [x: string]: PropertyDescriptor } {
if(model === null || model === Object.prototype) {
return {}
}
const prototypeDescriptors = getDescriptors(prototype)
const descriptors = Object.getOwnPropertyDescriptors(prototype)
return { ...prototypeDescriptors, ...descriptors }
const parentDescriptors = getDescriptors(Object.getPrototypeOf(model))
const descriptors = Object.getOwnPropertyDescriptors(model)
return { ...parentDescriptors, ...descriptors }
}

function getValue (value: Record<any, any>, path: string | string[]) {
const segments = typeof path === 'string'
? path.split('.')
: path
const segment: string | undefined = segments.shift()
return segment
? getValue(value[segment], segments)
: value
function getValue (value: Record<any, any>, path: string[]) {
const segment = path.shift()
return segment !== undefined ? getValue(value[segment], path) : value
}

export function makeReactive (model) {
// properties
const descriptors = getDescriptors(model)
// 'on.flag:target', 'on.flag1.flag2:target'
// flags: deep, immediate, pre, post, sync
let watchPattern = /^on(\.[.a-zA-Z]*)?:(.*)$/

// options
const data = {}
const watched = {}
function isWatch(key: string): boolean {
return watchPattern.test(key)
}

// data, string watches
Object.keys(model).forEach((key: string) => {
const value = model[key]
if (key.startsWith('on:')) {
watched[key.substring(3)] = value
function parseWatchOptions(key: string): [string, WatchOptions] {
let match = key.match(watchPattern)!
// the initial period will create an empty element, but all we do is check if specific values exist, so we don't care
let flags = new Set((match[1] ?? '').split('.'))
let target = match[2]
return [
target,
{
deep: flags.has('deep'),
immediate: flags.has('immediate'),
flush: flags.has('pre') ? 'pre' : flags.has('post') ? 'post' : flags.has('sync') ? 'sync' : undefined
}
else {
data[key] = value
]
}

/**
* Adds ComputedRef instances to the model for each getter/setter pair. When accessing the reactive object Vue will
* unwrap those refs. This method expects to be passed a reactive model.
*
* Before:
* ```js
* {
* <prototype>: {
* get x(),
* set x(value),
* get y(),
* },
* val: 10,
* get z(),
* set z(value),
* }
* ```
* After:
* ```js
* {
* <prototype>: {...},
* val: 10,
* x: computed({get: <prototype x getter>, set: <prototype x setter>}),
* y: computed(<prototype y getter>),
* z: computed({get: <original z getter>, set: <original z setter>}),
* }
* ```
*/
function addComputed(state) {
Object.entries(getDescriptors(state)).forEach(([key, desc]) => {
const {get, set} = desc
if(get) {
let ref = set
? computed({get: get.bind(state), set: set.bind(state)})
: computed(get.bind(state))

Object.defineProperty(state, key, {
value: ref, // vue unwraps this automatically when accessing it
writable: desc.writable,
enumerable: desc.enumerable,
configurable: true
})
}
})
}

// function watches, methods, computed
const state = reactive({
...data,
...Object.keys(descriptors).reduce((output, key) => {
if (key !== 'constructor' && !key.startsWith('__')) {
const { value, get, set } = descriptors[key]
// watch
if (key.startsWith('on:')) {
watched[key.substring(3)] = value
}
// method
else if (value) {
output[key] = (...args) => value.call(state, ...args)
}
// computed
else if (get && set) {
output[key] = computed({
set: (value) => set.call(state, value),
get: () => get.call(state),
})
}
else if (get) {
output[key] = computed(() => get.call(state))
}
/**
* Scans the model for `on:*` watchers and then creates watches for them. This method expects to be passed a reactive
* model.
*/
function addWatches (state) {
Object.entries(getDescriptors(state)).forEach(([key, desc]) => {
if (isWatch(key)) {
let [watchTarget, watchOptions] = parseWatchOptions(key)
let callback = typeof desc.value === 'string' ? state[desc.value] : desc.value
if(typeof callback === 'function') {
watch(() => getValue(state, watchTarget.split('.')), callback.bind(state), watchOptions)
}
return output
}, {}),
})

// set up watches
Object.keys(watched).forEach(key => {
const callback: Function = typeof watched[key] === 'string'
? model[getValue(model, 'on:' + key)]
: watched[key]
if (typeof callback === 'function') {
watch(() => getValue(state, key), callback.bind(state))
}
})
}

// return
return state
export function makeReactive<T extends object>(model: T): T {
// if the model is reactive (such as an object extending VueStore) this will return the model directly
const state = reactive(model)
addComputed(state)
addWatches(state)
return state as T
}

export default function VueStore<T extends C> (constructor: T): T {
function construct (...args: any[]) {
const instance = new (constructor as C)(...args)
return makeReactive(instance)
}
return (construct as unknown) as T
export interface VueStore {
new (): object
<T extends C> (constructor: T): T
create<T extends object> (model: T): T
}

const VueStore: VueStore = function VueStore (this: object, constructor?: C): any {
if(constructor === undefined) { // called as a constructor
return reactive(this)
} else { // called as a decorator
let wrapper = {
// preserve the constructor name. Useful for instanceof checks. https://stackoverflow.com/a/9479081
// the `]: function(` instead of `](` here is necessary, otherwise the function is declared using the es6 class
// syntax and thus can't be called as a constructor. https://stackoverflow.com/a/40922715
[constructor.name]: function(...args) {
return makeReactive(new constructor!(...args))
}
}[constructor.name]
// set the wrapper's `prototype` property to the wrapped class's prototype. This makes instanceof work.
wrapper.prototype = constructor.prototype
// set the prototype to the constructor instance so you can still access static methods/properties.
// This is how JS implements inheriting statics from superclasses, so it seems like a good solution.
Object.setPrototypeOf(wrapper, constructor)
return wrapper
}
} as VueStore

VueStore.create = makeReactive

export default VueStore
11 changes: 11 additions & 0 deletions tests/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"module": "CommonJS",
"sourceMap": false
},
"include": [
"node_modules/@types/mocha/index.d.ts",
"tests/**/*.ts"
]
}
Loading