From: Evan You Date: Wed, 19 Sep 2018 15:35:38 +0000 (-0400) Subject: init (graduate from prototype) X-Git-Tag: v3.0.0-alpha.0~1244 X-Git-Url: http://git.ipfire.org/gitweb.cgi?a=commitdiff_plain;h=3401f6b460196ce254a73df05ce571802b365054;p=thirdparty%2Fvuejs%2Fcore.git init (graduate from prototype) --- 3401f6b460196ce254a73df05ce571802b365054 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000..20fa3c8083 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +dist +.DS_Store +node_modules +explorations +TODOs.md diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000000..f5a1bdcdd2 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,3 @@ +semi: false +singleQuote: true +printWidth: 80 diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000000..25fa6215fd --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "typescript.tsdk": "node_modules/typescript/lib" +} diff --git a/lerna.json b/lerna.json new file mode 100644 index 0000000000..e8701ac92c --- /dev/null +++ b/lerna.json @@ -0,0 +1,6 @@ +{ + "packages": [ + "packages/*" + ], + "version": "3.0.0-alpha.1" +} diff --git a/package.json b/package.json new file mode 100644 index 0000000000..9e5ef914d0 --- /dev/null +++ b/package.json @@ -0,0 +1,26 @@ +{ + "private": true, + "workspaces": [ + "packages/*" + ], + "scripts": { + "dev": "node scripts/dev.js", + "build": "node scripts/build.js", + "lint": "prettier --write --parser typescript 'packages/*/src/**/*.ts'" + }, + "devDependencies": { + "chalk": "^2.4.1", + "dts-bundle": "^0.7.3", + "execa": "^1.0.0", + "fs-extra": "^7.0.0", + "lerna": "^3.4.0", + "minimist": "^1.2.0", + "prettier": "^1.14.2", + "rollup": "^0.65.0", + "rollup-plugin-alias": "^1.4.0", + "rollup-plugin-replace": "^2.0.0", + "rollup-plugin-terser": "^2.0.2", + "rollup-plugin-typescript2": "^0.17.0", + "typescript": "^3.0.3" + } +} diff --git a/packages/core/.npmignore b/packages/core/.npmignore new file mode 100644 index 0000000000..bb5c8a541b --- /dev/null +++ b/packages/core/.npmignore @@ -0,0 +1,3 @@ +__tests__/ +__mocks__/ +dist/packages \ No newline at end of file diff --git a/packages/core/README.md b/packages/core/README.md new file mode 100644 index 0000000000..48d5587a7d --- /dev/null +++ b/packages/core/README.md @@ -0,0 +1,3 @@ +# @vue/core + +> This package is published only for typing and building custom renderers. It is NOT meant to be used in applications. diff --git a/packages/core/index.js b/packages/core/index.js new file mode 100644 index 0000000000..435663ad40 --- /dev/null +++ b/packages/core/index.js @@ -0,0 +1,7 @@ +'use strict' + +if (process.env.NODE_ENV === 'production') { + module.exports = require('./dist/core.cjs.prod.js') +} else { + module.exports = require('./dist/core.cjs.js') +} diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000000..9d27e66cb7 --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,24 @@ +{ + "name": "@vue/core", + "version": "3.0.0-alpha.1", + "description": "@vue/core", + "main": "index.js", + "module": "dist/core.esm.js", + "typings": "dist/index.d.ts", + "repository": { + "type": "git", + "url": "git+https://github.com/vuejs/vue.git" + }, + "keywords": [ + "vue" + ], + "author": "Evan You", + "license": "MIT", + "bugs": { + "url": "https://github.com/vuejs/vue/issues" + }, + "homepage": "https://github.com/vuejs/vue/tree/dev/packages/core#readme", + "dependencies": { + "@vue/observer": "3.0.0-alpha.1" + } +} diff --git a/packages/core/src/component.ts b/packages/core/src/component.ts new file mode 100644 index 0000000000..54cb0874f0 --- /dev/null +++ b/packages/core/src/component.ts @@ -0,0 +1,171 @@ +import { EMPTY_OBJ } from './utils' +import { VNode, Slots, RenderNode, RenderFragment } from './vdom' +import { + Data, + RenderFunction, + ComponentOptions, + ComponentPropsOptions +} from './componentOptions' +import { setupWatcher } from './componentWatch' +import { Autorun, DebuggerEvent, ComputedGetter } from '@vue/observer' + +type Flatten = { [K in keyof T]: T[K] } + +export interface ComponentClass extends Flatten { + new (): MountedComponent & D & P +} + +export interface FunctionalComponent

extends RenderFunction

{ + pure?: boolean + props?: ComponentPropsOptions

+} + +// this interface is merged with the class type +// to represent a mounted component +export interface MountedComponent extends Component { + $vnode: VNode + $data: D + $props: P + $computed: Data + $slots: Slots + $root: MountedComponent + $children: MountedComponent[] + $options: ComponentOptions + + render: RenderFunction

+ data?(): Partial + beforeCreate?(): void + created?(): void + beforeMount?(): void + mounted?(): void + beforeUpdate?(e: DebuggerEvent): void + updated?(): void + beforeDestroy?(): void + destroyed?(): void + + _updateHandle: Autorun + $forceUpdate: () => void + + _self: MountedComponent // on proxies only +} + +export class Component { + public static options?: ComponentOptions + + public get $el(): RenderNode | RenderFragment | null { + return this.$vnode && this.$vnode.el + } + + public $vnode: VNode | null = null + public $parentVNode: VNode | null = null + public $data: Data | null = null + public $props: Data | null = null + public $computed: Data | null = null + public $slots: Slots | null = null + public $root: MountedComponent | null = null + public $parent: MountedComponent | null = null + public $children: MountedComponent[] = [] + public $options: any + public $proxy: any = null + public $forceUpdate: (() => void) | null = null + + public _rawData: Data | null = null + public _computedGetters: Record | null = null + public _watchHandles: Set | null = null + public _mounted: boolean = false + public _destroyed: boolean = false + public _events: { [event: string]: Function[] | null } | null = null + public _updateHandle: Autorun | null = null + public _revokeProxy: () => void + public _isVue: boolean = true + + constructor(options?: ComponentOptions) { + this.$options = options || (this.constructor as any).options || EMPTY_OBJ + // root instance + if (options !== void 0) { + // mount this + } + } + + $watch( + this: MountedComponent, + keyOrFn: string | (() => any), + cb: () => void + ) { + return setupWatcher(this, keyOrFn, cb) + } + + // eventEmitter interface + $on(event: string, fn: Function): Component { + if (Array.isArray(event)) { + for (let i = 0; i < event.length; i++) { + this.$on(event[i], fn) + } + } else { + const events = this._events || (this._events = Object.create(null)) + ;(events[event] || (events[event] = [])).push(fn) + } + return this + } + + $once(event: string, fn: Function): Component { + const onceFn = (...args: any[]) => { + this.$off(event, onceFn) + fn.apply(this, args) + } + ;(onceFn as any).fn = fn + return this.$on(event, onceFn) + } + + $off(event?: string, fn?: Function) { + if (this._events) { + if (!event && !fn) { + this._events = null + } else if (Array.isArray(event)) { + for (let i = 0; i < event.length; i++) { + this.$off(event[i], fn) + } + } else if (!fn) { + this._events[event as string] = null + } else { + const fns = this._events[event as string] + if (fns) { + for (let i = 0; i < fns.length; i++) { + const f = fns[i] + if (fn === f || fn === (f as any).fn) { + fns.splice(i, 1) + break + } + } + } + } + } + return this + } + + $emit(this: MountedComponent, name: string, ...payload: any[]) { + const parentListener = + this.$props['on' + name] || this.$props['on' + name.toLowerCase()] + if (parentListener) { + invokeListeners(parentListener, payload) + } + if (this._events) { + const handlers = this._events[name] + if (handlers) { + invokeListeners(handlers, payload) + } + } + return this + } +} + +function invokeListeners(value: Function | Function[], payload: any[]) { + // TODO handle error + if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) { + value[i](...payload) + } + } else { + value(...payload) + } +} diff --git a/packages/core/src/componentComputed.ts b/packages/core/src/componentComputed.ts new file mode 100644 index 0000000000..ab88958bc6 --- /dev/null +++ b/packages/core/src/componentComputed.ts @@ -0,0 +1,65 @@ +import { EMPTY_OBJ } from './utils' +import { computed, ComputedGetter } from '@vue/observer' +import { Component, ComponentClass } from './component' +import { ComponentComputedOptions } from './componentOptions' + +const extractionCache: WeakMap< + ComponentClass, + ComponentComputedOptions +> = new WeakMap() + +export function getComputedOptions( + comp: ComponentClass +): ComponentComputedOptions { + let computedOptions = extractionCache.get(comp) + if (computedOptions) { + return computedOptions + } + computedOptions = {} + const descriptors = Object.getOwnPropertyDescriptors(comp.prototype as any) + for (const key in descriptors) { + const d = descriptors[key] + if (d.get) { + computedOptions[key] = d.get + // there's no need to do anything for the setter + // as it's already defined on the prototype + } + } + return computedOptions +} + +export function initializeComputed( + instance: Component, + computedOptions: ComponentComputedOptions | undefined +) { + if (!computedOptions) { + instance.$computed = EMPTY_OBJ + return + } + const handles: Record< + string, + ComputedGetter + > = (instance._computedGetters = {}) + const proxy = instance.$proxy + for (const key in computedOptions) { + handles[key] = computed(computedOptions[key], proxy) + } + instance.$computed = new Proxy( + {}, + { + get(_, key: any) { + return handles[key]() + } + // TODO should be readonly + } + ) +} + +export function teardownComputed(instance: Component) { + const handles = instance._computedGetters + if (handles !== null) { + for (const key in handles) { + handles[key].stop() + } + } +} diff --git a/packages/core/src/componentOptions.ts b/packages/core/src/componentOptions.ts new file mode 100644 index 0000000000..2bcbf1a350 --- /dev/null +++ b/packages/core/src/componentOptions.ts @@ -0,0 +1,51 @@ +import { Slots } from './vdom' +import { MountedComponent } from './component' + +export type Data = Record + +export interface RenderFunction

{ + (props: P, slots: Slots): any +} + +export interface ComponentOptions { + data?: () => Partial + props?: ComponentPropsOptions

+ computed?: ComponentComputedOptions + watch?: ComponentWatchOptions + render?: RenderFunction

+ // TODO other options + readonly [key: string]: any +} + +export type ComponentPropsOptions

= { + [K in keyof P]: PropValidator +} + +export type NormalizedPropsOptions

= { + [K in keyof P]: PropOptions +} + +export type Prop = { (): T } | { new (...args: any[]): T & object } + +export type PropType = Prop | Prop[] + +export type PropValidator = PropOptions | PropType + +export interface PropOptions { + type?: PropType + required?: boolean + default?: T | null | undefined | (() => T | null | undefined) + validator?(value: T): boolean +} + +export interface ComponentComputedOptions { + [key: string]: (this: MountedComponent & D & P, c: any) => any +} + +export interface ComponentWatchOptions { + [key: string]: ( + this: MountedComponent & D & P, + oldValue: any, + newValue: any + ) => void +} diff --git a/packages/core/src/componentProps.ts b/packages/core/src/componentProps.ts new file mode 100644 index 0000000000..bfcec057b9 --- /dev/null +++ b/packages/core/src/componentProps.ts @@ -0,0 +1,103 @@ +import { EMPTY_OBJ, isReservedProp } from './utils' +import { Component, ComponentClass, MountedComponent } from './component' +import { immutable, unwrap, lock, unlock } from '@vue/observer' +import { + Data, + ComponentPropsOptions, + NormalizedPropsOptions, + PropValidator, + PropOptions +} from './componentOptions' + +export function initializeProps(instance: Component, props: Data | null) { + instance.$props = immutable(props || {}) +} + +export function updateProps(instance: MountedComponent, nextProps: Data) { + // instance.$props is an observable that should not be replaced. + // instead, we mutate it to match latest props, which will trigger updates + // if any value has changed. + if (nextProps != null) { + const props = instance.$props + const rawProps = unwrap(props) + // unlock to temporarily allow mutatiing props + unlock() + for (const key in rawProps) { + if (!nextProps.hasOwnProperty(key)) { + delete props[key] + } + } + for (const key in nextProps) { + props[key] = nextProps[key] + } + lock() + } +} + +// This is called for every component vnode created. This also means the data +// on every component vnode is guarunteed to be a fresh object. +export function normalizeComponentProps( + raw: any, + options: ComponentPropsOptions, + Component: ComponentClass +): Data { + if (!raw) { + return EMPTY_OBJ + } + const res: Data = {} + const normalizedOptions = options && normalizePropsOptions(options) + for (const key in raw) { + if (isReservedProp(key)) { + continue + } + if (__DEV__ && normalizedOptions != null) { + validateProp(key, raw[key], normalizedOptions[key], Component) + } else { + res[key] = raw[key] + } + } + // set default values + if (normalizedOptions != null) { + for (const key in normalizedOptions) { + if (res[key] === void 0) { + const opt = normalizedOptions[key] + if (opt != null && opt.hasOwnProperty('default')) { + const defaultValue = opt.default + res[key] = + typeof defaultValue === 'function' ? defaultValue() : defaultValue + } + } + } + } + return res +} + +const normalizeCache: WeakMap< + ComponentPropsOptions, + NormalizedPropsOptions +> = new WeakMap() +function normalizePropsOptions( + raw: ComponentPropsOptions +): NormalizedPropsOptions { + let cached = normalizeCache.get(raw) + if (cached) { + return cached + } + const normalized: NormalizedPropsOptions = {} + for (const key in raw) { + const opt = raw[key] + normalized[key] = + typeof opt === 'function' ? { type: opt } : (opt as PropOptions) + } + normalizeCache.set(raw, normalized) + return normalized +} + +function validateProp( + key: string, + value: any, + validator: PropValidator, + Component: ComponentClass +) { + // TODO +} diff --git a/packages/core/src/componentProxy.ts b/packages/core/src/componentProxy.ts new file mode 100644 index 0000000000..7b76439500 --- /dev/null +++ b/packages/core/src/componentProxy.ts @@ -0,0 +1,82 @@ +import { Component, MountedComponent } from './component' + +const bindCache = new WeakMap() + +function getBoundMethod(fn: Function, target: any, receiver: any): Function { + let boundMethodsForTarget = bindCache.get(target) + if (boundMethodsForTarget === void 0) { + bindCache.set(target, (boundMethodsForTarget = new Map())) + } + let boundFn = boundMethodsForTarget.get(fn) + if (boundFn === void 0) { + boundMethodsForTarget.set(fn, (boundFn = fn.bind(receiver))) + } + return boundFn +} + +const renderProxyHandlers = { + get(target: MountedComponent, key: string, receiver: any) { + if (key === '_self') { + return target + } else if ( + target._rawData !== null && + target._rawData.hasOwnProperty(key) + ) { + // data + return target.$data[key] + } else if ( + target.$options.props != null && + target.$options.props.hasOwnProperty(key) + ) { + // props are only proxied if declared + return target.$props[key] + } else if ( + target._computedGetters !== null && + target._computedGetters.hasOwnProperty(key) + ) { + // computed + return target._computedGetters[key]() + } else { + if (__DEV__ && !(key in target)) { + // TODO warn non-present property + } + const value = Reflect.get(target, key, receiver) + if (typeof value === 'function') { + // auto bind + return getBoundMethod(value, target, receiver) + } else { + return value + } + } + }, + set( + target: MountedComponent, + key: string, + value: any, + receiver: any + ): boolean { + if (__DEV__) { + if (typeof key === 'string' && key[0] === '$') { + // TODO warn setting immutable properties + return false + } + if ( + target.$options.props != null && + target.$options.props.hasOwnProperty(key) + ) { + // TODO warn props are immutable + return false + } + } + if (target._rawData !== null && target._rawData.hasOwnProperty(key)) { + target.$data[key] = value + return true + } else { + return Reflect.set(target, key, value, receiver) + } + } +} + +export function createRenderProxy(instance: Component): MountedComponent { + return new Proxy(instance, renderProxyHandlers) as MountedComponent +} diff --git a/packages/core/src/componentState.ts b/packages/core/src/componentState.ts new file mode 100644 index 0000000000..1adcd1d41c --- /dev/null +++ b/packages/core/src/componentState.ts @@ -0,0 +1,12 @@ +import { EMPTY_OBJ } from './utils' +import { MountedComponent } from './component' +import { observable } from '@vue/observer' + +export function initializeState(instance: MountedComponent) { + if (instance.data) { + instance._rawData = instance.data() + instance.$data = observable(instance._rawData) + } else { + instance.$data = EMPTY_OBJ + } +} diff --git a/packages/core/src/componentUtils.ts b/packages/core/src/componentUtils.ts new file mode 100644 index 0000000000..fe0e43af06 --- /dev/null +++ b/packages/core/src/componentUtils.ts @@ -0,0 +1,180 @@ +import { VNodeFlags } from './flags' +import { EMPTY_OBJ } from './utils' +import { VNode, createFragment } from './vdom' +import { Component, MountedComponent, ComponentClass } from './component' +import { createTextVNode, cloneVNode } from './vdom' +import { initializeState } from './componentState' +import { initializeProps } from './componentProps' +import { + initializeComputed, + getComputedOptions, + teardownComputed +} from './componentComputed' +import { initializeWatch, teardownWatch } from './componentWatch' +import { Data, ComponentOptions } from './componentOptions' +import { createRenderProxy } from './componentProxy' + +export function createComponentInstance( + vnode: VNode, + Component: ComponentClass, + parentComponent: MountedComponent | null +): MountedComponent { + const instance = (vnode.children = new Component()) as MountedComponent + instance.$parentVNode = vnode + + // renderProxy + const proxy = (instance.$proxy = createRenderProxy(instance)) + + // pointer management + if (parentComponent) { + instance.$parent = parentComponent.$proxy + instance.$root = parentComponent.$root + parentComponent.$children.push(proxy) + } else { + instance.$root = proxy + } + + // lifecycle + if (instance.beforeCreate) { + instance.beforeCreate.call(proxy) + } + // TODO provide/inject + initializeProps(instance, vnode.data) + initializeState(instance) + initializeComputed(instance, getComputedOptions(Component)) + initializeWatch(instance, instance.$options.watch) + instance.$slots = vnode.slots || EMPTY_OBJ + if (instance.created) { + instance.created.call(proxy) + } + + return instance as MountedComponent +} + +export function renderInstanceRoot(instance: MountedComponent) { + // TODO handle render error + return normalizeComponentRoot( + instance.render.call(instance.$proxy, instance.$props, instance.$slots), + instance.$parentVNode + ) +} + +export function teardownComponentInstance(instance: MountedComponent) { + const parentComponent = instance.$parent && instance.$parent._self + if (parentComponent && !parentComponent._destroyed) { + parentComponent.$children.splice( + parentComponent.$children.indexOf(instance.$proxy), + 1 + ) + } + teardownComputed(instance) + teardownWatch(instance) +} + +export function normalizeComponentRoot( + vnode: any, + componentVNode: VNode | null +): VNode { + if (vnode == null) { + vnode = createTextVNode('') + } else if (typeof vnode !== 'object') { + vnode = createTextVNode(vnode + '') + } else if (Array.isArray(vnode)) { + vnode = createFragment(vnode) + } else { + const { flags } = vnode + // parentVNode data merge down + if ( + componentVNode && + (flags & VNodeFlags.COMPONENT || flags & VNodeFlags.ELEMENT) + ) { + const parentData = componentVNode.data || EMPTY_OBJ + const childData = vnode.data || EMPTY_OBJ + let extraData: any = null + for (const key in parentData) { + // class/style bindings on parentVNode are merged down to child + // component root. + if (key === 'class') { + ;(extraData || (extraData = {})).class = childData.class + ? [].concat(childData.class, parentData.class) + : parentData.class + } else if (key === 'style') { + ;(extraData || (extraData = {})).style = childData.style + ? [].concat(childData.style, parentData.style) + : parentData.style + } else if (key.startsWith('nativeOn')) { + // nativeOn* handlers are merged down to child root as native listeners + const event = 'on' + key.slice(8) + ;(extraData || (extraData = {}))[event] = childData.event + ? [].concat(childData.event, parentData[key]) + : parentData[key] + } + } + if (extraData) { + vnode = cloneVNode(vnode, extraData) + } + if (vnode.el) { + vnode = cloneVNode(vnode) + } + if (flags & VNodeFlags.COMPONENT) { + vnode.parentVNode = componentVNode + } + } else if (vnode.el) { + vnode = cloneVNode(vnode) + } + } + return vnode +} + +export function shouldUpdateFunctionalComponent( + prevProps: Data | null, + nextProps: Data | null +): boolean { + if (prevProps === nextProps) { + return false + } + if (prevProps === null) { + return nextProps !== null + } + if (nextProps === null) { + return prevProps !== null + } + let shouldUpdate = true + const nextKeys = Object.keys(nextProps) + if (nextKeys.length === Object.keys(prevProps).length) { + shouldUpdate = false + for (let i = 0; i < nextKeys.length; i++) { + const key = nextKeys[i] + if (nextProps[key] !== prevProps[key]) { + shouldUpdate = true + } + } + } + return shouldUpdate +} + +export function createComponentClassFromOptions( + options: ComponentOptions +): ComponentClass { + class ObjectComponent extends Component { + constructor() { + super() + this.$options = options + } + } + for (const key in options) { + const value = options[key] + if (typeof value === 'function') { + ;(ObjectComponent.prototype as any)[key] = value + } + if (key === 'computed') { + const isGet = typeof value === 'function' + Object.defineProperty(ObjectComponent.prototype, key, { + configurable: true, + get: isGet ? value : value.get, + set: isGet ? undefined : value.set + }) + } + } + return ObjectComponent as ComponentClass +} diff --git a/packages/core/src/componentWatch.ts b/packages/core/src/componentWatch.ts new file mode 100644 index 0000000000..a8387021da --- /dev/null +++ b/packages/core/src/componentWatch.ts @@ -0,0 +1,50 @@ +import { MountedComponent } from './component' +import { ComponentWatchOptions } from './componentOptions' +import { autorun, stop, Autorun } from '@vue/observer' + +export function initializeWatch( + instance: MountedComponent, + options: ComponentWatchOptions | undefined +) { + if (options !== void 0) { + for (const key in options) { + setupWatcher(instance, key, options[key]) + } + } +} + +// TODO deep watch +export function setupWatcher( + instance: MountedComponent, + keyOrFn: string | Function, + cb: Function +): () => void { + const handles = instance._watchHandles || (instance._watchHandles = new Set()) + const proxy = instance.$proxy + const rawGetter = + typeof keyOrFn === 'string' + ? () => proxy[keyOrFn] + : () => keyOrFn.call(proxy) + let oldValue: any + const runner = autorun(rawGetter, { + scheduler: (runner: Autorun) => { + const newValue = runner() + if (newValue !== oldValue) { + cb(newValue, oldValue) + oldValue = newValue + } + } + }) + oldValue = runner() + handles.add(runner) + return () => { + stop(runner) + handles.delete(runner) + } +} + +export function teardownWatch(instance: MountedComponent) { + if (instance._watchHandles !== null) { + instance._watchHandles.forEach(stop) + } +} diff --git a/packages/core/src/createRenderer.ts b/packages/core/src/createRenderer.ts new file mode 100644 index 0000000000..5bf816be9a --- /dev/null +++ b/packages/core/src/createRenderer.ts @@ -0,0 +1,1309 @@ +import { autorun, stop } from '@vue/observer' +import { VNodeFlags, ChildrenFlags } from './flags' +import { EMPTY_OBJ, isReservedProp } from './utils' +import { + VNode, + MountedVNode, + MountedVNodes, + RenderNode, + createTextVNode, + cloneVNode, + Ref, + VNodeChildren, + RenderFragment +} from './vdom' +import { + MountedComponent, + FunctionalComponent, + ComponentClass +} from './component' +import { updateProps } from './componentProps' +import { + renderInstanceRoot, + createComponentInstance, + teardownComponentInstance, + normalizeComponentRoot, + shouldUpdateFunctionalComponent +} from './componentUtils' + +interface RendererOptions { + queueJob: (fn: () => void, postFlushJob?: () => void) => void + nodeOps: { + createElement: (tag: string, isSVG?: boolean) => any + createText: (text: string) => any + setText: (node: any, text: string) => void + appendChild: (parent: any, child: any) => void + insertBefore: (parent: any, child: any, ref: any) => void + replaceChild: (parent: any, oldChild: any, newChild: any) => void + removeChild: (parent: any, child: any) => void + clearContent: (node: any) => void + parentNode: (node: any) => any + nextSibling: (node: any) => any + querySelector: (selector: string) => any + } + patchData: ( + el: any, + key: string, + prevValue: any, + nextValue: any, + preVNode: VNode | null, + nextVNode: VNode, + isSVG: boolean, + // passed for DOM operations that removes child content + // e.g. innerHTML & textContent + unmountChildren: (children: VNode[], childFlags: ChildrenFlags) => void + ) => void + teardownVNode?: (vnode: VNode) => void +} + +// The whole mounting / patching / unmouting logic is placed inside this +// single function so that we can create multiple renderes with different +// platform definitions. This allows for use cases like creating a test +// renderer alongside an actual renderer. +export function createRenderer(options: RendererOptions) { + const { + queueJob, + nodeOps: { + createElement: platformCreateElement, + createText: platformCreateText, + setText: platformSetText, + appendChild: platformAppendChild, + insertBefore: platformInsertBefore, + replaceChild: platformReplaceChild, + removeChild: platformRemoveChild, + clearContent: platformClearContent, + parentNode: platformParentNode, + nextSibling: platformNextSibling, + querySelector: platformQuerySelector + }, + patchData: platformPatchData, + teardownVNode + } = options + + // Node operations (shimmed to handle virtual fragments) --------------------- + + function appendChild(container: RenderNode, el: RenderNode | RenderFragment) { + if (el.$f) { + for (let i = 0; i < el.children.length; i++) { + appendChild(container, el.children[i]) + } + } else { + platformAppendChild(container, el) + } + } + + function insertBefore( + container: RenderNode, + el: RenderNode | RenderFragment, + ref: RenderNode | RenderFragment + ) { + while (ref.$f) { + ref = ref.children[0] + } + if (el.$f) { + for (let i = 0; i < el.children.length; i++) { + insertBefore(container, el.children[i], ref) + } + } else { + platformInsertBefore(container, el, ref) + } + } + + function removeChild(container: RenderNode, el: RenderNode | RenderFragment) { + if (el.$f) { + for (let i = 0; i < el.children.length; i++) { + removeChild(container, el.children[i]) + } + } else { + platformRemoveChild(container, el) + } + } + + function replaceChild( + container: RenderNode, + oldChild: RenderNode | RenderFragment, + newChild: RenderNode | RenderFragment + ) { + if (oldChild.$f || newChild.$f) { + insertOrAppend(container, newChild, oldChild) + removeChild(container, oldChild) + } else { + platformReplaceChild(container, oldChild, newChild) + } + } + + function parentNode(el: RenderNode | RenderFragment): RenderNode { + while (el.$f) { + el = el.children[0] + } + return platformParentNode(el) + } + + function insertOrAppend( + container: RenderNode, + newNode: RenderNode | RenderFragment, + refNode: RenderNode | RenderFragment | null + ) { + // TODO special case for Fragment + if (refNode === null) { + appendChild(container, newNode) + } else { + insertBefore(container, newNode, refNode) + } + } + + // lifecycle hooks ----------------------------------------------------------- + + const hooks: Function[] = [] + + function queueHook(fn: Function) { + hooks.push(fn) + } + + function flushHooks() { + let fn + while ((fn = hooks.shift())) { + fn() + } + } + + // mounting ------------------------------------------------------------------ + + function mount( + vnode: VNode, + container: RenderNode | null, + parentComponent: MountedComponent | null, + isSVG: boolean, + endNode: RenderNode | RenderFragment | null + ): RenderNode | RenderFragment { + const { flags } = vnode + if (flags & VNodeFlags.ELEMENT) { + return mountElement(vnode, container, parentComponent, isSVG, endNode) + } else if (flags & VNodeFlags.COMPONENT) { + return mountComponent(vnode, container, parentComponent, isSVG, endNode) + } else if (flags & VNodeFlags.TEXT) { + return mountText(vnode, container, endNode) + } else if (flags & VNodeFlags.FRAGMENT) { + return mountFragment(vnode, container, parentComponent, isSVG, endNode) + } else if (flags & VNodeFlags.PORTAL) { + return mountPortal(vnode, container, parentComponent) + } else { + return platformCreateText('') + } + } + + function mountArrayChildren( + children: VNode[], + container: RenderNode | null, + parentComponent: MountedComponent | null, + isSVG: boolean, + endNode: RenderNode | RenderFragment | null + ) { + for (let i = 0; i < children.length; i++) { + let child = children[i] + if (child.el) { + children[i] = child = cloneVNode(child) + } + mount(children[i], container, parentComponent, isSVG, endNode) + } + } + + function mountElement( + vnode: VNode, + container: RenderNode | null, + parentComponent: MountedComponent | null, + isSVG: boolean, + endNode: RenderNode | RenderFragment | null + ): RenderNode { + const { flags, tag, data, children, childFlags, ref } = vnode + isSVG = isSVG || (flags & VNodeFlags.ELEMENT_SVG) > 0 + const el = (vnode.el = platformCreateElement(tag as string, isSVG)) + if (data != null) { + for (const key in data) { + patchData(el, key, null, data[key], null, vnode, isSVG) + } + } + if (childFlags !== ChildrenFlags.NO_CHILDREN) { + const hasSVGChildren = isSVG && tag !== 'foreignObject' + if (childFlags & ChildrenFlags.SINGLE_VNODE) { + mount(children as VNode, el, parentComponent, hasSVGChildren, endNode) + } else if (childFlags & ChildrenFlags.MULTIPLE_VNODES) { + mountArrayChildren( + children as VNode[], + el, + parentComponent, + hasSVGChildren, + endNode + ) + } + } + if (container != null) { + insertOrAppend(container, el, endNode) + } + if (ref) { + mountRef(ref, el) + } + return el + } + + function mountRef(ref: Ref, el: RenderNode | MountedComponent) { + queueHook(() => { + ref(el) + }) + } + + function mountComponent( + vnode: VNode, + container: RenderNode | null, + parentComponent: MountedComponent | null, + isSVG: boolean, + endNode: RenderNode | RenderFragment | null + ): RenderNode | RenderFragment { + let el: RenderNode | RenderFragment + const { flags, tag, data, slots } = vnode + if (flags & VNodeFlags.COMPONENT_STATEFUL) { + el = mountComponentInstance( + vnode, + tag as ComponentClass, + container, + parentComponent, + isSVG, + endNode + ) + } else { + // functional component + const subTree = (vnode.children = normalizeComponentRoot( + (tag as FunctionalComponent)(data || EMPTY_OBJ, slots || EMPTY_OBJ), + vnode + )) + el = vnode.el = mount(subTree, null, parentComponent, isSVG, null) + } + if (container != null) { + insertOrAppend(container, el, endNode) + } + return el + } + + function mountText( + vnode: VNode, + container: RenderNode | null, + endNode: RenderNode | RenderFragment | null + ): RenderNode { + const el = (vnode.el = platformCreateText(vnode.children as string)) + if (container != null) { + insertOrAppend(container, el, endNode) + } + return el + } + + function mountFragment( + vnode: VNode, + container: RenderNode | null, + parentComponent: MountedComponent | null, + isSVG: boolean, + endNode: RenderNode | RenderFragment | null + ): RenderFragment { + const { children, childFlags } = vnode + const fragment: RenderFragment = (vnode.el = { + $f: true, + children: [] + }) + const fragmentChildren = fragment.children + if (childFlags & ChildrenFlags.SINGLE_VNODE) { + fragmentChildren.push( + mount(children as VNode, container, parentComponent, isSVG, endNode) + ) + } else if (childFlags & ChildrenFlags.MULTIPLE_VNODES) { + mountArrayChildren( + children as VNode[], + container, + parentComponent, + isSVG, + endNode + ) + for (let i = 0; i < (children as MountedVNodes).length; i++) { + fragmentChildren.push((children as MountedVNodes)[i].el) + } + } else { + // ensure at least one children so that it can be used as a ref node + // during insertions + fragmentChildren.push(mountText(createTextVNode(''), container, endNode)) + } + return fragment + } + + function mountPortal( + vnode: VNode, + container: RenderNode | null, + parentComponent: MountedComponent | null + ): RenderNode { + const { tag, children, childFlags, ref } = vnode + const target = typeof tag === 'string' ? platformQuerySelector(tag) : tag + + if (__DEV__ && !target) { + // TODO warn poartal target not found + } + + if (childFlags & ChildrenFlags.SINGLE_VNODE) { + mount( + children as VNode, + target as RenderNode, + parentComponent, + false, + null + ) + } else if (childFlags & ChildrenFlags.MULTIPLE_VNODES) { + mountArrayChildren( + children as VNode[], + target as RenderNode, + parentComponent, + false, + null + ) + } + if (ref) { + mountRef(ref, target as RenderNode) + } + return (vnode.el = mountText(createTextVNode(''), container, null)) + } + + // patching ------------------------------------------------------------------ + + function patchData( + el: RenderNode, + key: string, + prevValue: any, + nextValue: any, + preVNode: VNode | null, + nextVNode: VNode, + isSVG: boolean + ) { + if (isReservedProp(key)) { + return + } + platformPatchData( + el, + key, + prevValue, + nextValue, + preVNode, + nextVNode, + isSVG, + unmountChildren + ) + } + + function patch( + prevVNode: VNode, + nextVNode: VNode, + container: RenderNode, + parentComponent: MountedComponent | null, + isSVG: boolean + ) { + const nextFlags = nextVNode.flags + const prevFlags = prevVNode.flags + + if (prevFlags !== nextFlags) { + replaceVNode(prevVNode, nextVNode, container, parentComponent, isSVG) + } else if (nextFlags & VNodeFlags.ELEMENT) { + patchElement(prevVNode, nextVNode, container, parentComponent, isSVG) + } else if (nextFlags & VNodeFlags.COMPONENT) { + patchComponent(prevVNode, nextVNode, container, parentComponent, isSVG) + } else if (nextFlags & VNodeFlags.TEXT) { + patchText(prevVNode, nextVNode) + } else if (nextFlags & VNodeFlags.FRAGMENT) { + patchFragment(prevVNode, nextVNode, container, parentComponent, isSVG) + } else if (nextFlags & VNodeFlags.PORTAL) { + patchPortal(prevVNode, nextVNode, parentComponent) + } + } + + function patchElement( + prevVNode: VNode, + nextVNode: VNode, + container: RenderNode, + parentComponent: MountedComponent | null, + isSVG: boolean + ) { + const { flags, tag } = nextVNode + isSVG = isSVG || (flags & VNodeFlags.ELEMENT_SVG) > 0 + + if (prevVNode.tag !== tag) { + replaceVNode(prevVNode, nextVNode, container, parentComponent, isSVG) + return + } + + const el = (nextVNode.el = prevVNode.el) as RenderNode + + // data + const prevData = prevVNode.data + const nextData = nextVNode.data + if (prevData !== nextData) { + const prevDataOrEmpty = prevData || EMPTY_OBJ + const nextDataOrEmpty = nextData || EMPTY_OBJ + if (nextDataOrEmpty !== EMPTY_OBJ) { + for (const key in nextDataOrEmpty) { + const prevValue = prevDataOrEmpty[key] + const nextValue = nextDataOrEmpty[key] + if (prevValue !== nextValue) { + patchData( + el, + key, + prevValue, + nextValue, + prevVNode, + nextVNode, + isSVG + ) + } + } + } + if (prevDataOrEmpty !== EMPTY_OBJ) { + for (const key in prevDataOrEmpty) { + const prevValue = prevDataOrEmpty[key] + if (prevValue != null && !nextDataOrEmpty.hasOwnProperty(key)) { + patchData(el, key, prevValue, null, prevVNode, nextVNode, isSVG) + } + } + } + } + + // children + patchChildren( + prevVNode.childFlags, + nextVNode.childFlags, + prevVNode.children, + nextVNode.children, + el, + parentComponent, + isSVG && nextVNode.tag !== 'foreignObject', + null + ) + } + + function patchComponent( + prevVNode: VNode, + nextVNode: VNode, + container: RenderNode, + parentComponent: MountedComponent | null, + isSVG: boolean + ) { + const { tag, flags } = nextVNode + if (tag !== prevVNode.tag) { + replaceVNode(prevVNode, nextVNode, container, parentComponent, isSVG) + } else if (flags & VNodeFlags.COMPONENT_STATEFUL) { + patchStatefulComponent(prevVNode, nextVNode) + } else { + patchFunctionalComponent( + prevVNode, + nextVNode, + container, + parentComponent, + isSVG + ) + } + } + + function patchStatefulComponent(prevVNode: VNode, nextVNode: VNode) { + const { childFlags: prevChildFlags } = prevVNode + const { + data: nextProps, + slots: nextSlots, + childFlags: nextChildFlags + } = nextVNode + + const instance = (nextVNode.children = + prevVNode.children) as MountedComponent + instance.$slots = nextSlots || EMPTY_OBJ + instance.$parentVNode = nextVNode + + // Update props. This will trigger child update if necessary. + if (nextProps !== null) { + updateProps(instance, nextProps) + } + + // If has different slots content, or has non-compiled slots, + // the child needs to be force updated. It's ok to call $forceUpdate + // again even if props update has already queued an update, as the + // scheduler will not queue the same update twice. + const shouldForceUpdate = + prevChildFlags !== nextChildFlags || + (nextChildFlags & ChildrenFlags.DYNAMIC_SLOTS) > 0 + if (shouldForceUpdate) { + instance.$forceUpdate() + } else if (instance.$vnode.flags & VNodeFlags.COMPONENT) { + instance.$vnode.parentVNode = nextVNode + } + nextVNode.el = instance.$vnode.el + } + + function patchFunctionalComponent( + prevVNode: VNode, + nextVNode: VNode, + container: RenderNode, + parentComponent: MountedComponent | null, + isSVG: boolean + ) { + // functional component tree is stored on the vnode as `children` + const { data: prevProps, slots: prevSlots } = prevVNode + const { data: nextProps, slots: nextSlots } = nextVNode + const render = nextVNode.tag as FunctionalComponent + const prevTree = prevVNode.children as VNode + + let shouldUpdate = true + if (render.pure && prevSlots == null && nextSlots == null) { + shouldUpdate = shouldUpdateFunctionalComponent(prevProps, nextProps) + } + + if (shouldUpdate) { + const nextTree = (nextVNode.children = normalizeComponentRoot( + render(nextProps || EMPTY_OBJ, nextSlots || EMPTY_OBJ), + nextVNode + )) + patch(prevTree, nextTree, container, parentComponent, isSVG) + nextVNode.el = nextTree.el + } else if (prevTree.flags & VNodeFlags.COMPONENT) { + // functional component returned another component + prevTree.parentVNode = nextVNode + } + } + + function patchFragment( + prevVNode: VNode, + nextVNode: VNode, + container: RenderNode, + parentComponent: MountedComponent | null, + isSVG: boolean + ) { + // determine the tail node of the previous fragment, + // then retrieve its next sibling to use as the end node for patchChildren. + let prevElement = prevVNode.el as RenderNode | RenderFragment + while (prevElement.$f) { + prevElement = prevElement.children[prevElement.children.length - 1] + } + const { children, childFlags } = nextVNode + patchChildren( + prevVNode.childFlags, + childFlags, + prevVNode.children, + children, + container, + parentComponent, + isSVG, + platformNextSibling(prevElement) + ) + nextVNode.el = prevVNode.el as RenderFragment + const fragmentChildren: ( + | RenderNode + | RenderFragment)[] = (nextVNode.el.children = []) + if (childFlags & ChildrenFlags.SINGLE_VNODE) { + fragmentChildren.push((children as MountedVNode).el) + } else if (childFlags & ChildrenFlags.MULTIPLE_VNODES) { + for (let i = 0; i < (children as MountedVNodes).length; i++) { + fragmentChildren.push((children as MountedVNodes)[i].el) + } + } else { + fragmentChildren.push(mountText(createTextVNode(''), null, null)) + } + } + + function patchText(prevVNode: VNode, nextVNode: VNode) { + const el = (nextVNode.el = prevVNode.el) as RenderNode + const nextText = nextVNode.children + if (nextText !== prevVNode.children) { + platformSetText(el, nextText as string) + } + } + + function patchPortal( + prevVNode: VNode, + nextVNode: VNode, + parentComponent: MountedComponent | null + ) { + const prevContainer = prevVNode.tag as RenderNode + const nextContainer = nextVNode.tag as RenderNode + const nextChildren = nextVNode.children + patchChildren( + prevVNode.childFlags, + nextVNode.childFlags, + prevVNode.children, + nextChildren, + prevContainer, + parentComponent, + false, + null + ) + nextVNode.el = prevVNode.el + if (nextContainer !== prevContainer) { + switch (nextVNode.childFlags) { + case ChildrenFlags.SINGLE_VNODE: + appendChild(nextContainer, (nextChildren as MountedVNode).el) + break + case ChildrenFlags.NO_CHILDREN: + break + default: + for (let i = 0; i < (nextChildren as MountedVNodes).length; i++) { + appendChild(nextContainer, (nextChildren as MountedVNodes)[i].el) + } + break + } + } + } + + function replaceVNode( + prevVNode: VNode, + nextVNode: VNode, + container: RenderNode, + parentComponent: MountedComponent | null, + isSVG: boolean + ) { + unmount(prevVNode) + replaceChild( + container, + prevVNode.el as RenderNode | RenderFragment, + mount(nextVNode, null, parentComponent, isSVG, null) + ) + } + + function patchChildren( + prevChildFlags: ChildrenFlags, + nextChildFlags: ChildrenFlags, + prevChildren: VNodeChildren, + nextChildren: VNodeChildren, + container: RenderNode, + parentComponent: MountedComponent | null, + isSVG: boolean, + endNode: RenderNode | RenderFragment | null + ) { + switch (prevChildFlags) { + case ChildrenFlags.SINGLE_VNODE: + switch (nextChildFlags) { + case ChildrenFlags.SINGLE_VNODE: + patch( + prevChildren as VNode, + nextChildren as VNode, + container, + parentComponent, + isSVG + ) + break + case ChildrenFlags.NO_CHILDREN: + remove(prevChildren as VNode, container) + break + default: + remove(prevChildren as VNode, container) + mountArrayChildren( + nextChildren as VNode[], + container, + parentComponent, + isSVG, + endNode + ) + break + } + break + case ChildrenFlags.NO_CHILDREN: + switch (nextChildFlags) { + case ChildrenFlags.SINGLE_VNODE: + mount( + nextChildren as VNode, + container, + parentComponent, + isSVG, + endNode + ) + break + case ChildrenFlags.NO_CHILDREN: + break + default: + mountArrayChildren( + nextChildren as VNode[], + container, + parentComponent, + isSVG, + endNode + ) + break + } + break + default: + // MULTIPLE_CHILDREN + if (nextChildFlags === ChildrenFlags.SINGLE_VNODE) { + removeAll(prevChildren as MountedVNodes, container, endNode) + mount( + nextChildren as VNode, + container, + parentComponent, + isSVG, + endNode + ) + } else if (nextChildFlags === ChildrenFlags.NO_CHILDREN) { + removeAll(prevChildren as MountedVNodes, container, endNode) + } else { + const prevLength = (prevChildren as VNode[]).length + const nextLength = (nextChildren as VNode[]).length + if (prevLength === 0) { + if (nextLength > 0) { + mountArrayChildren( + nextChildren as VNode[], + container, + parentComponent, + isSVG, + endNode + ) + } + } else if (nextLength === 0) { + removeAll(prevChildren as MountedVNodes, container, endNode) + } else if ( + prevChildFlags === ChildrenFlags.KEYED_VNODES && + nextChildFlags === ChildrenFlags.KEYED_VNODES + ) { + patchKeyedChildren( + prevChildren as VNode[], + nextChildren as VNode[], + container, + prevLength, + nextLength, + parentComponent, + isSVG, + endNode + ) + } else { + patchNonKeyedChildren( + prevChildren as VNode[], + nextChildren as VNode[], + container, + prevLength, + nextLength, + parentComponent, + isSVG, + endNode + ) + } + } + break + } + } + + function patchNonKeyedChildren( + prevChildren: VNode[], + nextChildren: VNode[], + container: RenderNode, + prevLength: number, + nextLength: number, + parentComponent: MountedComponent | null, + isSVG: boolean, + endNode: RenderNode | RenderFragment | null + ) { + const commonLength = prevLength > nextLength ? nextLength : prevLength + let i = 0 + let nextChild + let prevChild + for (i; i < commonLength; i++) { + nextChild = nextChildren[i] + prevChild = prevChildren[i] + if (nextChild.el) { + nextChildren[i] = nextChild = cloneVNode(nextChild) + } + patch(prevChild, nextChild, container, parentComponent, isSVG) + prevChildren[i] = nextChild + } + if (prevLength < nextLength) { + for (i = commonLength; i < nextLength; i++) { + nextChild = nextChildren[i] + if (nextChild.el) { + nextChildren[i] = nextChild = cloneVNode(nextChild) + } + mount(nextChild, container, parentComponent, isSVG, endNode) + } + } else if (prevLength > nextLength) { + for (i = commonLength; i < prevLength; i++) { + remove(prevChildren[i], container) + } + } + } + + function patchKeyedChildren( + prevChildren: VNode[], + nextChildren: VNode[], + container: RenderNode, + prevLength: number, + nextLength: number, + parentComponent: MountedComponent | null, + isSVG: boolean, + endNode: RenderNode | RenderFragment | null + ) { + let prevEnd = prevLength - 1 + let nextEnd = nextLength - 1 + let i + let j = 0 + let prevVNode = prevChildren[j] + let nextVNode = nextChildren[j] + let nextPos + + outer: { + // Sync nodes with the same key at the beginning. + while (prevVNode.key === nextVNode.key) { + if (nextVNode.el) { + nextChildren[j] = nextVNode = cloneVNode(nextVNode) + } + patch(prevVNode, nextVNode, container, parentComponent, isSVG) + prevChildren[j] = nextVNode + j++ + if (j > prevEnd || j > nextEnd) { + break outer + } + prevVNode = prevChildren[j] + nextVNode = nextChildren[j] + } + + prevVNode = prevChildren[prevEnd] + nextVNode = nextChildren[nextEnd] + + // Sync nodes with the same key at the end. + while (prevVNode.key === nextVNode.key) { + if (nextVNode.el) { + nextChildren[nextEnd] = nextVNode = cloneVNode(nextVNode) + } + patch(prevVNode, nextVNode, container, parentComponent, isSVG) + prevChildren[prevEnd] = nextVNode + prevEnd-- + nextEnd-- + if (j > prevEnd || j > nextEnd) { + break outer + } + prevVNode = prevChildren[prevEnd] + nextVNode = nextChildren[nextEnd] + } + } + + if (j > prevEnd) { + if (j <= nextEnd) { + nextPos = nextEnd + 1 + const nextNode = + nextPos < nextLength ? nextChildren[nextPos].el : endNode + while (j <= nextEnd) { + nextVNode = nextChildren[j] + if (nextVNode.el) { + nextChildren[j] = nextVNode = cloneVNode(nextVNode) + } + j++ + mount(nextVNode, container, parentComponent, isSVG, nextNode) + } + } + } else if (j > nextEnd) { + while (j <= prevEnd) { + remove(prevChildren[j++], container) + } + } else { + let prevStart = j + const nextStart = j + const prevLeft = prevEnd - j + 1 + const nextLeft = nextEnd - j + 1 + const sources: number[] = [] + for (i = 0; i < nextLeft; i++) { + sources.push(0) + } + // Keep track if its possible to remove whole DOM using textContent = '' + let canRemoveWholeContent = prevLeft === prevLength + let moved = false + let pos = 0 + let patched = 0 + + // When sizes are small, just loop them through + if (nextLength < 4 || (prevLeft | nextLeft) < 32) { + for (i = prevStart; i <= prevEnd; i++) { + prevVNode = prevChildren[i] + if (patched < nextLeft) { + for (j = nextStart; j <= nextEnd; j++) { + nextVNode = nextChildren[j] + if (prevVNode.key === nextVNode.key) { + sources[j - nextStart] = i + 1 + if (canRemoveWholeContent) { + canRemoveWholeContent = false + while (i > prevStart) { + remove(prevChildren[prevStart++], container) + } + } + if (pos > j) { + moved = true + } else { + pos = j + } + if (nextVNode.el) { + nextChildren[j] = nextVNode = cloneVNode(nextVNode) + } + patch(prevVNode, nextVNode, container, parentComponent, isSVG) + patched++ + break + } + } + if (!canRemoveWholeContent && j > nextEnd) { + remove(prevVNode, container) + } + } else if (!canRemoveWholeContent) { + remove(prevVNode, container) + } + } + } else { + const keyIndex: Record = {} + + // Map keys by their index + for (i = nextStart; i <= nextEnd; i++) { + keyIndex[nextChildren[i].key as string] = i + } + + // Try to patch same keys + for (i = prevStart; i <= prevEnd; i++) { + prevVNode = prevChildren[i] + + if (patched < nextLeft) { + j = keyIndex[prevVNode.key as string] + + if (j !== void 0) { + if (canRemoveWholeContent) { + canRemoveWholeContent = false + while (i > prevStart) { + remove(prevChildren[prevStart++], container) + } + } + nextVNode = nextChildren[j] + sources[j - nextStart] = i + 1 + if (pos > j) { + moved = true + } else { + pos = j + } + if (nextVNode.el) { + nextChildren[j] = nextVNode = cloneVNode(nextVNode) + } + patch(prevVNode, nextVNode, container, parentComponent, isSVG) + patched++ + } else if (!canRemoveWholeContent) { + remove(prevVNode, container) + } + } else if (!canRemoveWholeContent) { + remove(prevVNode, container) + } + } + } + // fast-path: if nothing patched remove all old and add all new + if (canRemoveWholeContent) { + removeAll(prevChildren as MountedVNodes, container, endNode) + mountArrayChildren( + nextChildren, + container, + parentComponent, + isSVG, + endNode + ) + } else { + if (moved) { + const seq = lis(sources) + j = seq.length - 1 + for (i = nextLeft - 1; i >= 0; i--) { + if (sources[i] === 0) { + pos = i + nextStart + nextVNode = nextChildren[pos] + if (nextVNode.el) { + nextChildren[pos] = nextVNode = cloneVNode(nextVNode) + } + nextPos = pos + 1 + mount( + nextVNode, + container, + parentComponent, + isSVG, + nextPos < nextLength ? nextChildren[nextPos].el : endNode + ) + } else if (j < 0 || i !== seq[j]) { + pos = i + nextStart + nextVNode = nextChildren[pos] + nextPos = pos + 1 + insertOrAppend( + container, + nextVNode.el as RenderNode | RenderFragment, + nextPos < nextLength ? nextChildren[nextPos].el : endNode + ) + } else { + j-- + } + } + } else if (patched !== nextLeft) { + // when patched count doesn't match b length we need to insert those + // new ones loop backwards so we can use insertBefore + for (i = nextLeft - 1; i >= 0; i--) { + if (sources[i] === 0) { + pos = i + nextStart + nextVNode = nextChildren[pos] + if (nextVNode.el) { + nextChildren[pos] = nextVNode = cloneVNode(nextVNode) + } + nextPos = pos + 1 + mount( + nextVNode, + container, + parentComponent, + isSVG, + nextPos < nextLength ? nextChildren[nextPos].el : endNode + ) + } + } + } + } + } + } + + // https://en.wikipedia.org/wiki/Longest_increasing_subsequence + function lis(arr: number[]): number[] { + const p = arr.slice() + const result = [0] + let i + let j + let u + let v + let c + const len = arr.length + for (i = 0; i < len; i++) { + const arrI = arr[i] + if (arrI !== 0) { + j = result[result.length - 1] + if (arr[j] < arrI) { + p[i] = j + result.push(i) + continue + } + u = 0 + v = result.length - 1 + while (u < v) { + c = ((u + v) / 2) | 0 + if (arr[result[c]] < arrI) { + u = c + 1 + } else { + v = c + } + } + if (arrI < arr[result[u]]) { + if (u > 0) { + p[i] = result[u - 1] + } + result[u] = i + } + } + } + u = result.length + v = result[u - 1] + while (u-- > 0) { + result[u] = v + v = p[v] + } + return result + } + + // unmounting ---------------------------------------------------------------- + + function unmount(vnode: VNode) { + const { flags, children, childFlags, ref } = vnode + if (flags & VNodeFlags.ELEMENT || flags & VNodeFlags.FRAGMENT) { + unmountChildren(children as VNodeChildren, childFlags) + if (teardownVNode !== void 0) { + teardownVNode(vnode) + } + } else if (flags & VNodeFlags.COMPONENT) { + if (flags & VNodeFlags.COMPONENT_STATEFUL) { + unmountComponentInstance(children as MountedComponent) + } else { + unmount(children as VNode) + } + } else if (flags & VNodeFlags.PORTAL) { + if (childFlags & ChildrenFlags.MULTIPLE_VNODES) { + removeAll(children as MountedVNodes, vnode.tag as RenderNode, null) + } else if (childFlags === ChildrenFlags.SINGLE_VNODE) { + remove(children as VNode, vnode.tag as RenderNode) + } + } + if (ref) { + ref(null) + } + } + + function unmountChildren(children: VNodeChildren, childFlags: ChildrenFlags) { + if (childFlags & ChildrenFlags.MULTIPLE_VNODES) { + unmountArrayChildren(children as VNode[]) + } else if (childFlags === ChildrenFlags.SINGLE_VNODE) { + unmount(children as VNode) + } + } + + function unmountArrayChildren(children: VNode[]) { + for (let i = 0; i < children.length; i++) { + unmount(children[i]) + } + } + + function remove(vnode: VNode, container: RenderNode) { + unmount(vnode) + if (container && vnode.el) { + removeChild(container, vnode.el) + vnode.el = null + } + } + + function removeAll( + children: MountedVNodes, + container: RenderNode, + ref: RenderNode | RenderFragment | null + ) { + unmountArrayChildren(children) + if (ref === null) { + platformClearContent(container) + } else { + for (let i = 0; i < children.length; i++) { + removeChild(container, children[i].el as RenderNode | RenderFragment) + } + } + } + + // Component lifecycle ------------------------------------------------------- + + function mountComponentInstance( + parentVNode: VNode, + Component: ComponentClass, + container: RenderNode | null, + parentComponent: MountedComponent | null, + isSVG: boolean, + endNode: RenderNode | RenderFragment | null + ): RenderNode { + const instance = createComponentInstance( + parentVNode, + Component, + parentComponent + ) + + const queueUpdate = (instance.$forceUpdate = () => { + queueJob(instance._updateHandle, flushHooks) + }) + + instance._updateHandle = autorun( + () => { + if (instance._destroyed) { + return + } + if (instance._mounted) { + updateComponentInstance(instance, container, isSVG) + } else { + // this will be executed synchronously right here + instance.$vnode = renderInstanceRoot(instance) + parentVNode.el = mount( + instance.$vnode, + container, + instance, + isSVG, + endNode + ) + instance._mounted = true + mountComponentInstanceCallbacks(instance, parentVNode.ref) + } + }, + { + scheduler: queueUpdate, + onTrigger: + instance.beforeUpdate && instance.beforeUpdate.bind(instance.$proxy) + } + ) + + return parentVNode.el as RenderNode + } + + function mountComponentInstanceCallbacks( + instance: MountedComponent, + ref: Ref | null + ) { + if (instance.beforeMount) { + instance.beforeMount.call(instance.$proxy) + } + if (ref) { + mountRef(ref, instance) + } + if (instance.mounted) { + queueHook(() => { + ;(instance as any).mounted.call(instance.$proxy) + }) + } + } + + function updateComponentInstance( + instance: MountedComponent, + container: RenderNode | null, + isSVG: boolean + ) { + // beforeUpdate is called as the onTrack hook of the instance's reactive + // runner + const prevVNode = instance.$vnode + const nextVNode = (instance.$vnode = renderInstanceRoot(instance)) + container = + container || parentNode(prevVNode.el as RenderNode | RenderFragment) + patch(prevVNode, nextVNode, container, instance, isSVG) + const el = nextVNode.el as RenderNode + + // recursively update parentVNode el for nested HOCs + if ((nextVNode.flags & VNodeFlags.PORTAL) === 0) { + let vnode = instance.$parentVNode + while (vnode !== null) { + if ((vnode.flags & VNodeFlags.COMPONENT) > 0) { + vnode.el = el + } + vnode = vnode.parentVNode + } + } + + if (instance.updated) { + queueHook(() => { + ;(instance as any).updated.call(instance.$proxy, nextVNode) + }) + } + } + + function unmountComponentInstance(instance: MountedComponent) { + if (instance.beforeDestroy) { + instance.beforeDestroy.call(instance.$proxy) + } + if (instance.$vnode) { + unmount(instance.$vnode) + } + stop(instance._updateHandle) + teardownComponentInstance(instance) + instance._destroyed = true + if (instance.destroyed) { + instance.destroyed.call(instance.$proxy) + } + } + + // TODO hydrating ------------------------------------------------------------ + + // API ----------------------------------------------------------------------- + + function render(vnode: VNode | null, container: RenderNode) { + const prevVNode = container.vnode + if (vnode && vnode.el) { + vnode = cloneVNode(vnode) + } + if (prevVNode == null) { + if (vnode) { + mount(vnode, container, null, false, null) + container.vnode = vnode + } + } else { + if (vnode) { + patch(prevVNode, vnode, container, null, false) + container.vnode = vnode + } else { + remove(prevVNode, container) + container.vnode = null + } + } + flushHooks() + } + + return { render } +} diff --git a/packages/core/src/errorHandling.ts b/packages/core/src/errorHandling.ts new file mode 100644 index 0000000000..7714bcf45f --- /dev/null +++ b/packages/core/src/errorHandling.ts @@ -0,0 +1,26 @@ +import { MountedComponent } from './component' + +export const enum ErrorTypes { + LIFECYCLE = 1, + RENDER = 2, + NATIVE_EVENT_HANDLER = 3, + COMPONENT_EVENT_HANDLER = 4 +} + +const globalHandlers: Function[] = [] + +export function globalHandleError(handler: () => void) { + globalHandlers.push(handler) + return () => { + globalHandlers.splice(globalHandlers.indexOf(handler), 1) + } +} + +export function handleError( + err: Error, + instance: MountedComponent, + type: ErrorTypes, + code: number +) { + // TODO +} diff --git a/packages/core/src/flags.ts b/packages/core/src/flags.ts new file mode 100644 index 0000000000..c248c4e5a7 --- /dev/null +++ b/packages/core/src/flags.ts @@ -0,0 +1,31 @@ +// vnode flags +export const enum VNodeFlags { + ELEMENT_HTML = 1, + ELEMENT_SVG = 1 << 1, + ELEMENT = ELEMENT_HTML | ELEMENT_SVG, + + COMPONENT_UNKNOWN = 1 << 2, + COMPONENT_STATEFUL = 1 << 3, + COMPONENT_FUNCTIONAL = 1 << 4, + COMPONENT_ASYNC = 1 << 5, + COMPONENT = COMPONENT_UNKNOWN | + COMPONENT_STATEFUL | + COMPONENT_FUNCTIONAL | + COMPONENT_ASYNC, + + TEXT = 1 << 6, + FRAGMENT = 1 << 7, + PORTAL = 1 << 8 +} + +export const enum ChildrenFlags { + UNKNOWN_CHILDREN = 0, + NO_CHILDREN = 1, + SINGLE_VNODE = 1 << 1, + KEYED_VNODES = 1 << 2, + NONE_KEYED_VNODES = 1 << 3, + STABLE_SLOTS = 1 << 4, + DYNAMIC_SLOTS = 1 << 5, + HAS_SLOTS = STABLE_SLOTS | DYNAMIC_SLOTS, + MULTIPLE_VNODES = KEYED_VNODES | NONE_KEYED_VNODES +} diff --git a/packages/core/src/h.ts b/packages/core/src/h.ts new file mode 100644 index 0000000000..99adfac600 --- /dev/null +++ b/packages/core/src/h.ts @@ -0,0 +1,104 @@ +import { ChildrenFlags } from './flags' +import { ComponentClass, FunctionalComponent } from './component' +import { ComponentOptions } from './componentOptions' +import { + VNode, + createElementVNode, + createComponentVNode, + createTextVNode, + createFragment, + createPortal +} from './vdom' + +export const Fragment = Symbol() +export const Portal = Symbol() + +type ElementType = + | string + | FunctionalComponent + | ComponentClass + | ComponentOptions + | typeof Fragment + | typeof Portal + +export interface createElement { + (tag: ElementType, data: any, children: any): VNode + c: typeof createComponentVNode + e: typeof createElementVNode + t: typeof createTextVNode + f: typeof createFragment + p: typeof createPortal +} + +export const h = ((tag: ElementType, data: any, children: any): VNode => { + if (Array.isArray(data) || (data !== void 0 && typeof data !== 'object')) { + children = data + data = null + } + + // TODO clone data if it is observed + + let key = null + let ref = null + let portalTarget = null + if (data != null) { + if (data.slots != null) { + children = data.slots + } + if (data.key != null) { + ;({ key } = data) + } + if (data.ref != null) { + ;({ ref } = data) + } + if (data.target != null) { + portalTarget = data.target + } + } + + if (typeof tag === 'string') { + // element + return createElementVNode( + tag, + data, + children, + ChildrenFlags.UNKNOWN_CHILDREN, + key, + ref + ) + } else if (tag === Fragment) { + if (__DEV__ && ref) { + // TODO warn fragment cannot have ref + } + return createFragment(children, ChildrenFlags.UNKNOWN_CHILDREN, key) + } else if (tag === Portal) { + if (__DEV__ && !portalTarget) { + // TODO warn portal must have a target + } + return createPortal( + portalTarget, + children, + ChildrenFlags.UNKNOWN_CHILDREN, + key, + ref + ) + } else { + // TODO: handle fragment & portal types + // TODO: warn ref on fragment + // component + return createComponentVNode( + tag, + data, + children, + ChildrenFlags.UNKNOWN_CHILDREN, + key, + ref + ) + } +}) as createElement + +h.c = createComponentVNode +h.e = createElementVNode +h.t = createTextVNode +h.f = createFragment +h.p = createPortal diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 0000000000..f37e98e324 --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,29 @@ +// render api +export { h, Fragment, Portal } from './h' +export { cloneVNode, createPortal, createFragment } from './vdom' +export { createRenderer } from './createRenderer' + +import { Component as InternalComponent, ComponentClass } from './component' + +// the public component constructor with proper type inference. +export const Component = InternalComponent as ComponentClass + +// observer api +export { + autorun, + stop, + observable, + immutable, + computed, + isObservable, + isImmutable, + markImmutable, + markNonReactive, + unwrap +} from '@vue/observer' + +// flags & types +export { FunctionalComponent } from './component' +export { ComponentOptions, PropType } from './componentOptions' +export { VNodeFlags, ChildrenFlags } from './flags' +export { VNode, VNodeData, VNodeChildren, Key, Ref, Slots, Slot } from './vdom' diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts new file mode 100644 index 0000000000..465c0d9b38 --- /dev/null +++ b/packages/core/src/utils.ts @@ -0,0 +1,12 @@ +export const EMPTY_OBJ: { readonly [key: string]: any } = Object.freeze({}) + +export const isReservedProp = (key: string): boolean => { + switch (key) { + case 'key': + case 'ref': + case 'slots': + return true + default: + return key.startsWith('nativeOn') + } +} diff --git a/packages/core/src/vdom.ts b/packages/core/src/vdom.ts new file mode 100644 index 0000000000..519ea095c5 --- /dev/null +++ b/packages/core/src/vdom.ts @@ -0,0 +1,360 @@ +import { + MountedComponent, + ComponentClass, + FunctionalComponent +} from './component' +import { VNodeFlags, ChildrenFlags } from './flags' +import { normalizeComponentProps } from './componentProps' +import { createComponentClassFromOptions } from './componentUtils' +import { ComponentPropsOptions } from './componentOptions' + +// Vue core is platform agnostic, so we are not using Element for "DOM" nodes. +export interface RenderNode { + vnode?: VNode | null + // technically this doesn't exist on platforn render nodes, + // but we list it here so that TS can figure out union types + $f: false +} + +export interface RenderFragment { + children: (RenderNode | RenderFragment)[] + $f: true +} + +export interface VNode { + _isVNode: true + flags: VNodeFlags + tag: string | FunctionalComponent | ComponentClass | RenderNode | null + data: VNodeData | null + children: VNodeChildren + childFlags: ChildrenFlags + key: Key | null + ref: Ref | null + slots: Slots | null + // only on mounted nodes + el: RenderNode | RenderFragment | null + // only on mounted component root nodes + // points to component node in parent tree + parentVNode: VNode | null +} + +export interface MountedVNode extends VNode { + el: RenderNode | RenderFragment +} + +export type MountedVNodes = MountedVNode[] + +export interface VNodeData { + key?: Key | null + ref?: Ref | null + slots?: Slots | null + [key: string]: any +} + +export type VNodeChildren = + | VNode[] // ELEMENT | PORTAL + | MountedComponent // COMPONENT_STATEFUL + | VNode // COMPONENT_FUNCTIONAL + | string // TEXT + | null + +export type Key = string | number + +export type Ref = (t: RenderNode | MountedComponent | null) => void + +export interface Slots { + [name: string]: Slot +} + +export type Slot = (...args: any[]) => VNode[] + +export function createVNode( + flags: VNodeFlags, + tag: string | FunctionalComponent | ComponentClass | RenderNode | null, + data: VNodeData | null, + children: VNodeChildren | null, + childFlags: ChildrenFlags, + key: Key | null | undefined, + ref: Ref | null | undefined, + slots: Slots | null | undefined +): VNode { + const vnode: VNode = { + _isVNode: true, + flags, + tag, + data, + children, + childFlags, + key: key === void 0 ? null : key, + ref: ref === void 0 ? null : ref, + slots: slots === void 0 ? null : slots, + el: null, + parentVNode: null + } + if (childFlags === ChildrenFlags.UNKNOWN_CHILDREN) { + normalizeChildren(vnode, children) + } + return vnode +} + +export function createElementVNode( + tag: string, + data: VNodeData | null, + children: VNodeChildren, + childFlags: ChildrenFlags, + key?: Key | null, + ref?: Ref | null +) { + const flags = tag === 'svg' ? VNodeFlags.ELEMENT_SVG : VNodeFlags.ELEMENT_HTML + return createVNode(flags, tag, data, children, childFlags, key, ref, null) +} + +export function createComponentVNode( + comp: any, + data: VNodeData | null, + children: VNodeChildren, + childFlags: ChildrenFlags, + key?: Key | null, + ref?: Ref | null +) { + // resolve type + let flags: VNodeFlags + let propsOptions: ComponentPropsOptions + + // flags + const compType = typeof comp + if (__COMPAT__ && compType === 'object') { + if (comp.functional) { + // object literal functional + flags = VNodeFlags.COMPONENT_FUNCTIONAL + const { render } = comp + if (!comp._normalized) { + render.pure = comp.pure + render.props = comp.props + comp._normalized = true + } + comp = render + propsOptions = comp.props + } else { + // object literal stateful + flags = VNodeFlags.COMPONENT_STATEFUL + comp = + comp._normalized || + (comp._normalized = createComponentClassFromOptions(comp)) + propsOptions = comp.options && comp.options.props + } + } else { + // assumes comp is function here now + if (__DEV__ && compType !== 'function') { + // TODO warn invalid comp value in dev + } + if (comp.prototype && comp.prototype.render) { + flags = VNodeFlags.COMPONENT_STATEFUL + propsOptions = comp.options && comp.options.props + } else { + flags = VNodeFlags.COMPONENT_FUNCTIONAL + propsOptions = comp.props + } + } + + if (__DEV__ && flags === VNodeFlags.COMPONENT_FUNCTIONAL && ref) { + // TODO warn functional component cannot have ref + } + + // props + const props = normalizeComponentProps(data, propsOptions, comp) + + // slots + let slots: any + if (childFlags == null) { + childFlags = children + ? ChildrenFlags.DYNAMIC_SLOTS + : ChildrenFlags.NO_CHILDREN + if (children != null) { + const childrenType = typeof children + if (childrenType === 'function') { + // function as children + slots = { default: children } + } else if (childrenType === 'object' && !(children as VNode)._isVNode) { + // slot object as children + slots = children + } else { + slots = { default: () => children } + } + slots = normalizeSlots(slots) + } + } + + return createVNode( + flags, + comp, + props, + null, // to be set during mount + childFlags, + key, + ref, + slots + ) +} + +export function createTextVNode(text: string): VNode { + return createVNode( + VNodeFlags.TEXT, + null, + null, + text == null ? '' : text, + ChildrenFlags.NO_CHILDREN, + null, + null, + null + ) +} + +export function createFragment( + children: VNodeChildren, + childFlags?: ChildrenFlags, + key?: Key | null +) { + return createVNode( + VNodeFlags.FRAGMENT, + null, + null, + children, + childFlags === void 0 ? ChildrenFlags.UNKNOWN_CHILDREN : childFlags, + key, + null, + null + ) +} + +export function createPortal( + target: RenderNode | string, + children: VNodeChildren, + childFlags?: ChildrenFlags, + key?: Key | null, + ref?: Ref | null +): VNode { + return createVNode( + VNodeFlags.PORTAL, + target, + null, + children, + childFlags === void 0 ? ChildrenFlags.UNKNOWN_CHILDREN : childFlags, + key, + ref, + null + ) +} + +export function cloneVNode(vnode: VNode, extraData?: VNodeData): VNode { + const { flags, data } = vnode + if (flags & VNodeFlags.ELEMENT || flags & VNodeFlags.COMPONENT) { + let clonedData = data + if (extraData != null) { + clonedData = {} + if (data != null) { + for (const key in data) { + clonedData[key] = data[key] + } + } + for (const key in extraData) { + clonedData[key] = extraData[key] + } + } + return createVNode( + flags, + vnode.tag, + clonedData, + vnode.children, + vnode.childFlags, + vnode.key, + vnode.ref, + vnode.slots + ) + } else if (flags & VNodeFlags.TEXT) { + return createTextVNode(vnode.children as string) + } else { + return vnode + } +} + +function normalizeChildren(vnode: VNode, children: any) { + let childFlags + if (Array.isArray(children)) { + const { length } = children + if (length === 0) { + childFlags = ChildrenFlags.NO_CHILDREN + children = null + } else if (length === 1) { + childFlags = ChildrenFlags.SINGLE_VNODE + children = children[0] + if (children.el) { + children = cloneVNode(children) + } + } else { + childFlags = ChildrenFlags.KEYED_VNODES + children = normalizeVNodes(children) + } + } else if (children == null) { + childFlags = ChildrenFlags.NO_CHILDREN + } else if (children._isVNode) { + childFlags = ChildrenFlags.SINGLE_VNODE + if (children.el) { + children = cloneVNode(children) + } + } else { + // primitives or invalid values, cast to string + childFlags = ChildrenFlags.SINGLE_VNODE + children = createTextVNode(children + '') + } + vnode.children = children + vnode.childFlags = childFlags +} + +export function normalizeVNodes( + children: any[], + newChildren: VNode[] = [], + currentPrefix: string = '' +): VNode[] { + for (let i = 0; i < children.length; i++) { + const child = children[i] + let newChild + if (child == null) { + newChild = createTextVNode('') + } else if (child._isVNode) { + newChild = child.el ? cloneVNode(child) : child + } else if (Array.isArray(child)) { + normalizeVNodes(child, newChildren, currentPrefix + i + '|') + } else { + newChild = createTextVNode(child + '') + } + if (newChild) { + if (newChild.key == null) { + newChild.key = currentPrefix + i + } + newChildren.push(newChild) + } + } + return newChildren +} + +// ensure all slot functions return Arrays +function normalizeSlots(slots: { [name: string]: any }): Slots { + const normalized: Slots = {} + for (const name in slots) { + normalized[name] = (...args) => normalizeSlot(slots[name](...args)) + } + return normalized +} + +function normalizeSlot(value: any): VNode[] { + if (value == null) { + return [createTextVNode('')] + } else if (Array.isArray(value)) { + return normalizeVNodes(value) + } else if (value._isVNode) { + return [value] + } else { + return [createTextVNode(value + '')] + } +} diff --git a/packages/global.d.ts b/packages/global.d.ts new file mode 100644 index 0000000000..11614e940d --- /dev/null +++ b/packages/global.d.ts @@ -0,0 +1,3 @@ +// Global compile-time constants +declare var __DEV__: boolean +declare var __COMPAT__: boolean diff --git a/packages/observer/.npmignore b/packages/observer/.npmignore new file mode 100644 index 0000000000..bb5c8a541b --- /dev/null +++ b/packages/observer/.npmignore @@ -0,0 +1,3 @@ +__tests__/ +__mocks__/ +dist/packages \ No newline at end of file diff --git a/packages/observer/README.md b/packages/observer/README.md new file mode 100644 index 0000000000..a6c7f10029 --- /dev/null +++ b/packages/observer/README.md @@ -0,0 +1,3 @@ +# @vue/observer + +> This package is inlined into UMD & Browser ESM builds of user-facing renderers (e.g. `@vue/runtime-dom`), but also published as a package that can be used standalone. The standalone build should not be used alongside a pre-bundled build of a user-facing renderer, as they will have different internal storage for reactivity connections. A user-facing renderer should re-export all APIs from this package. diff --git a/packages/observer/index.js b/packages/observer/index.js new file mode 100644 index 0000000000..50a792c2df --- /dev/null +++ b/packages/observer/index.js @@ -0,0 +1,7 @@ +'use strict' + +if (process.env.NODE_ENV === 'production') { + module.exports = require('./dist/observer.cjs.prod.js') +} else { + module.exports = require('./dist/observer.cjs.js') +} diff --git a/packages/observer/package.json b/packages/observer/package.json new file mode 100644 index 0000000000..81bccff669 --- /dev/null +++ b/packages/observer/package.json @@ -0,0 +1,26 @@ +{ + "name": "@vue/observer", + "version": "3.0.0-alpha.1", + "description": "@vue/observer", + "main": "index.js", + "module": "dist/observer.esm.js", + "typings": "dist/index.d.ts", + "unpkg": "dist/observer.umd.js", + "repository": { + "type": "git", + "url": "git+https://github.com/vuejs/vue.git" + }, + "buildOptions": { + "name": "VueObserver", + "formats": ["esm", "cjs", "umd", "esm-browser"] + }, + "keywords": [ + "vue" + ], + "author": "Evan You", + "license": "MIT", + "bugs": { + "url": "https://github.com/vuejs/vue/issues" + }, + "homepage": "https://github.com/vuejs/vue/tree/dev/packages/observer#readme" +} diff --git a/packages/observer/src/autorun.ts b/packages/observer/src/autorun.ts new file mode 100644 index 0000000000..6752bf76d0 --- /dev/null +++ b/packages/observer/src/autorun.ts @@ -0,0 +1,164 @@ +import { OperationTypes } from './operations' +import { Dep, KeyToDepMap, targetMap } from './state' + +export interface Autorun { + (): any + isAutorun: true + active: boolean + raw: Function + deps: Array + scheduler?: Scheduler + onTrack?: Debugger + onTrigger?: Debugger +} + +export interface AutorunOptions { + lazy?: boolean + scheduler?: Scheduler + onTrack?: Debugger + onTrigger?: Debugger +} + +export type Scheduler = (run: () => any) => void + +export type DebuggerEvent = { + runner: Autorun + target: any + type: OperationTypes + key: string | symbol | undefined +} + +export type Debugger = (event: DebuggerEvent) => void + +export const activeAutorunStack: Autorun[] = [] + +const ITERATE_KEY = Symbol('iterate') + +export function createAutorun(fn: Function, options: AutorunOptions): Autorun { + const runner = function runner(...args): any { + return run(runner as Autorun, fn, args) + } as Autorun + runner.active = true + runner.raw = fn + runner.scheduler = options.scheduler + runner.onTrack = options.onTrack + runner.onTrigger = options.onTrigger + runner.deps = [] + return runner +} + +function run(runner: Autorun, fn: Function, args: any[]): any { + if (!runner.active) { + return fn(...args) + } + if (activeAutorunStack.indexOf(runner) === -1) { + cleanup(runner) + try { + activeAutorunStack.push(runner) + return fn(...args) + } finally { + activeAutorunStack.pop() + } + } +} + +export function cleanup(runner: Autorun) { + for (let i = 0; i < runner.deps.length; i++) { + runner.deps[i].delete(runner) + } + runner.deps = [] +} + +export function track( + target: any, + type: OperationTypes, + key?: string | symbol +) { + const runner = activeAutorunStack[activeAutorunStack.length - 1] + if (runner) { + if (type === OperationTypes.ITERATE) { + key = ITERATE_KEY + } + // keyMap must exist because only an observed target can call this function + const depsMap = targetMap.get(target) as KeyToDepMap + let dep = depsMap.get(key as string | symbol) + if (!dep) { + depsMap.set(key as string | symbol, (dep = new Set())) + } + if (!dep.has(runner)) { + dep.add(runner) + runner.deps.push(dep) + if (__DEV__ && runner.onTrack) { + runner.onTrack({ + runner, + target, + type, + key + }) + } + } + } +} + +export function trigger( + target: any, + type: OperationTypes, + key?: string | symbol, + extraInfo?: any +) { + const depsMap = targetMap.get(target) as KeyToDepMap + const runners = new Set() + if (type === OperationTypes.CLEAR) { + // collection being cleared, trigger all runners for target + depsMap.forEach(dep => { + addRunners(runners, dep) + }) + } else { + // schedule runs for SET | ADD | DELETE + addRunners(runners, depsMap.get(key as string | symbol)) + // also run for iteration key on ADD | DELETE + if (type === OperationTypes.ADD || type === OperationTypes.DELETE) { + const iterationKey = Array.isArray(target) ? 'length' : ITERATE_KEY + addRunners(runners, depsMap.get(iterationKey)) + } + } + runners.forEach(runner => { + scheduleRun(runner, target, type, key, extraInfo) + }) +} + +function addRunners( + runners: Set, + runnersToAdd: Set | undefined +) { + if (runnersToAdd !== void 0) { + runnersToAdd.forEach(runners.add, runners) + } +} + +function scheduleRun( + runner: Autorun, + target: any, + type: OperationTypes, + key: string | symbol | undefined, + extraInfo: any +) { + if (__DEV__ && runner.onTrigger) { + runner.onTrigger( + Object.assign( + { + runner, + target, + key, + type + }, + extraInfo + ) + ) + } + if (runner.scheduler !== void 0) { + runner.scheduler(runner) + } else { + runner() + } +} diff --git a/packages/observer/src/baseHandlers.ts b/packages/observer/src/baseHandlers.ts new file mode 100644 index 0000000000..15240ac7dd --- /dev/null +++ b/packages/observer/src/baseHandlers.ts @@ -0,0 +1,120 @@ +import { observable, immutable, unwrap } from './index' +import { OperationTypes } from './operations' +import { track, trigger } from './autorun' +import { LOCKED } from './lock' + +const hasOwnProperty = Object.prototype.hasOwnProperty + +const builtInSymbols = new Set( + Object.getOwnPropertyNames(Symbol) + .map(key => (Symbol as any)[key]) + .filter(value => typeof value === 'symbol') +) + +function get( + target: any, + key: string | symbol, + receiver: any, + toObsevable: (t: any) => any +) { + const res = Reflect.get(target, key, receiver) + if (typeof key === 'symbol' && builtInSymbols.has(key)) { + return res + } + track(target, OperationTypes.GET, key) + return res !== null && typeof res === 'object' ? toObsevable(res) : res +} + +function set( + target: any, + key: string | symbol, + value: any, + receiver: any +): boolean { + value = unwrap(value) + const hadKey = hasOwnProperty.call(target, key) + const oldValue = target[key] + const result = Reflect.set(target, key, value, receiver) + // don't trigger if target is something up in the prototype chain of original + if (target === unwrap(receiver)) { + if (__DEV__) { + const extraInfo = { oldValue, newValue: value } + if (!hadKey) { + trigger(target, OperationTypes.ADD, key, extraInfo) + } else if (value !== oldValue) { + trigger(target, OperationTypes.SET, key, extraInfo) + } + } else { + if (!hadKey) { + trigger(target, OperationTypes.ADD, key) + } else if (value !== oldValue) { + trigger(target, OperationTypes.SET, key) + } + } + } + return result +} + +function deleteProperty(target: any, key: string | symbol): boolean { + const hadKey = hasOwnProperty.call(target, key) + const oldValue = target[key] + const result = Reflect.deleteProperty(target, key) + if (hadKey) { + if (__DEV__) { + trigger(target, OperationTypes.DELETE, key, { oldValue }) + } else { + trigger(target, OperationTypes.DELETE, key) + } + } + return result +} + +function has(target: any, key: string | symbol): boolean { + const result = Reflect.has(target, key) + track(target, OperationTypes.HAS, key) + return result +} + +function ownKeys(target: any): (string | number | symbol)[] { + track(target, OperationTypes.ITERATE) + return Reflect.ownKeys(target) +} + +export const mutableHandlers: ProxyHandler = { + get: (target: any, key: string | symbol, receiver: any) => + get(target, key, receiver, observable), + set, + deleteProperty, + has, + ownKeys +} + +export const immutableHandlers: ProxyHandler = { + get: (target: any, key: string | symbol, receiver: any) => + get(target, key, receiver, LOCKED ? immutable : observable), + + set(target: any, key: string | symbol, value: any, receiver: any): boolean { + if (LOCKED) { + if (__DEV__) { + console.warn(`Set operation failed: target is immutable.`, target) + } + return true + } else { + return set(target, key, value, receiver) + } + }, + + deleteProperty(target: any, key: string | symbol): boolean { + if (LOCKED) { + if (__DEV__) { + console.warn(`Delete operation failed: target is immutable.`, target) + } + return true + } else { + return deleteProperty(target, key) + } + }, + + has, + ownKeys +} diff --git a/packages/observer/src/collectionHandlers.ts b/packages/observer/src/collectionHandlers.ts new file mode 100644 index 0000000000..859677e976 --- /dev/null +++ b/packages/observer/src/collectionHandlers.ts @@ -0,0 +1,161 @@ +import { unwrap } from './index' +import { track, trigger } from './autorun' +import { OperationTypes } from './operations' + +function instrument( + target: any, + key: string | symbol, + args: any[], + type: OperationTypes +) { + target = unwrap(target) + const proto: any = Reflect.getPrototypeOf(target) + track(target, type) + return proto[key].apply(target, args) +} + +function get(key: string | symbol) { + return instrument(this, key, [key], OperationTypes.GET) +} + +function has(key: string | symbol): boolean { + return instrument(this, key, [key], OperationTypes.HAS) +} + +function size(target: any) { + target = unwrap(target) + const proto = Reflect.getPrototypeOf(target) + track(target, OperationTypes.ITERATE) + return Reflect.get(proto, 'size', target) +} + +function makeWarning(type: OperationTypes) { + return function() { + if (__DEV__) { + console.warn( + `${type} operation failed: target is immutable.`, + unwrap(this) + ) + } + } +} + +const mutableInstrumentations: any = { + get, + has, + + get size() { + return size(this) + }, + + add(key: any) { + const target = unwrap(this) + const proto: any = Reflect.getPrototypeOf(this) + const hadKey = proto.has.call(target, key) + const result = proto.add.apply(target, arguments) + if (!hadKey) { + if (__DEV__) { + trigger(target, OperationTypes.ADD, key, { value: key }) + } else { + trigger(target, OperationTypes.ADD, key) + } + } + return result + }, + + set(key: any, value: any) { + const target = unwrap(this) + const proto: any = Reflect.getPrototypeOf(this) + const hadKey = proto.has.call(target, key) + const oldValue = proto.get.call(target, key) + const result = proto.set.apply(target, arguments) + if (__DEV__) { + const extraInfo = { oldValue, newValue: value } + if (!hadKey) { + trigger(target, OperationTypes.ADD, key, extraInfo) + } else { + trigger(target, OperationTypes.SET, key, extraInfo) + } + } else { + if (!hadKey) { + trigger(target, OperationTypes.ADD, key) + } else { + trigger(target, OperationTypes.SET, key) + } + } + return result + }, + + delete(key: any) { + const target = unwrap(this) + const proto: any = Reflect.getPrototypeOf(this) + const hadKey = proto.has.call(target, key) + const oldValue = proto.get ? proto.get.call(target, key) : undefined + // forward the operation before queueing reactions + const result = proto.delete.apply(target, arguments) + if (hadKey) { + if (__DEV__) { + trigger(target, OperationTypes.DELETE, key, { oldValue }) + } else { + trigger(target, OperationTypes.DELETE, key) + } + } + return result + }, + + clear() { + const target = unwrap(this) + const proto: any = Reflect.getPrototypeOf(this) + const hadItems = target.size !== 0 + const oldTarget = target instanceof Map ? new Map(target) : new Set(target) + // forward the operation before queueing reactions + const result = proto.clear.apply(target, arguments) + if (hadItems) { + if (__DEV__) { + trigger(target, OperationTypes.CLEAR, void 0, { oldTarget }) + } else { + trigger(target, OperationTypes.CLEAR) + } + } + return result + } +} + +const immutableInstrumentations: any = { + get, + has, + get size() { + return size(this) + }, + add: makeWarning(OperationTypes.ADD), + set: makeWarning(OperationTypes.SET), + delete: makeWarning(OperationTypes.DELETE), + clear: makeWarning(OperationTypes.CLEAR) +} +;['forEach', 'keys', 'values', 'entries', Symbol.iterator].forEach(key => { + mutableInstrumentations[key] = immutableInstrumentations[key] = function( + ...args: any[] + ) { + return instrument(this, key, args, OperationTypes.ITERATE) + } +}) + +function getInstrumented( + target: any, + key: string | symbol, + receiver: any, + instrumentations: any +) { + target = instrumentations.hasOwnProperty(key) ? instrumentations : target + return Reflect.get(target, key, receiver) +} + +export const mutableCollectionHandlers: ProxyHandler = { + get: (target: any, key: string | symbol, receiver: any) => + getInstrumented(target, key, receiver, mutableInstrumentations) +} + +export const immutableCollectionHandlers: ProxyHandler = { + get: (target: any, key: string | symbol, receiver: any) => + getInstrumented(target, key, receiver, immutableInstrumentations) +} diff --git a/packages/observer/src/computed.ts b/packages/observer/src/computed.ts new file mode 100644 index 0000000000..01a4f7e791 --- /dev/null +++ b/packages/observer/src/computed.ts @@ -0,0 +1,44 @@ +import { autorun, stop } from './index' +import { Autorun, activeAutorunStack } from './autorun' + +export interface ComputedGetter { + (): any + stop: () => void +} + +export function computed(getter: Function, context?: any): ComputedGetter { + let dirty: boolean = true + let value: any = undefined + const runner = autorun(() => getter.call(context, context), { + lazy: true, + scheduler: () => { + dirty = true + } + }) + const computedGetter = (() => { + if (dirty) { + value = runner() + dirty = false + } + // When computed autoruns are accessed in a parent autorun, the parent + // should track all the dependencies the computed property has tracked. + // This should also apply for chained computed properties. + trackChildRun(runner) + return value + }) as ComputedGetter + computedGetter.stop = () => stop(runner) + return computedGetter +} + +function trackChildRun(childRunner: Autorun) { + const parentRunner = activeAutorunStack[activeAutorunStack.length - 1] + if (parentRunner) { + for (let i = 0; i < childRunner.deps.length; i++) { + const dep = childRunner.deps[i] + if (!dep.has(parentRunner)) { + dep.add(parentRunner) + parentRunner.deps.push(dep) + } + } + } +} diff --git a/packages/observer/src/index.ts b/packages/observer/src/index.ts new file mode 100644 index 0000000000..1aa0c1d1b4 --- /dev/null +++ b/packages/observer/src/index.ts @@ -0,0 +1,152 @@ +import { mutableHandlers, immutableHandlers } from './baseHandlers' + +import { + mutableCollectionHandlers, + immutableCollectionHandlers +} from './collectionHandlers' + +import { + targetMap, + observedToRaw, + rawToObserved, + immutableToRaw, + rawToImmutable, + immutableValues, + nonReactiveValues +} from './state' + +import { + createAutorun, + cleanup, + Autorun, + AutorunOptions, + DebuggerEvent +} from './autorun' + +export { Autorun, DebuggerEvent } +export { computed, ComputedGetter } from './computed' +export { lock, unlock } from './lock' + +const EMPTY_OBJ = {} +const collectionTypes: Set = new Set([Set, Map, WeakMap, WeakSet]) +const observableValueRE = /^\[object (?:Object|Array|Map|Set|WeakMap|WeakSet)\]$/ + +const canObserve = (value: any): boolean => { + return ( + !value._isVue && + !value._isVNode && + observableValueRE.test(Object.prototype.toString.call(value)) && + !nonReactiveValues.has(value) + ) +} + +type identity = (target: T) => T + +export const observable = ((target: any = {}): any => { + // if trying to observe an immutable proxy, return the immutable version. + if (immutableToRaw.has(target)) { + return target + } + // target is explicitly marked as immutable by user + if (immutableValues.has(target)) { + return immutable(target) + } + return createObservable( + target, + rawToObserved, + observedToRaw, + mutableHandlers, + mutableCollectionHandlers + ) +}) as identity + +export const immutable = ((target: any = {}): any => { + // value is a mutable observable, retrive its original and return + // a readonly version. + if (observedToRaw.has(target)) { + target = observedToRaw.get(target) + } + return createObservable( + target, + rawToImmutable, + immutableToRaw, + immutableHandlers, + immutableCollectionHandlers + ) +}) as identity + +function createObservable( + target: any, + toProxy: WeakMap, + toRaw: WeakMap, + baseHandlers: ProxyHandler, + collectionHandlers: ProxyHandler +) { + if ((__DEV__ && target === null) || typeof target !== 'object') { + throw new Error(`value is not observable: ${String(target)}`) + } + // target already has corresponding Proxy + let observed = toProxy.get(target) + if (observed !== void 0) { + return observed + } + // target is already a Proxy + if (toRaw.has(target)) { + return target + } + // only a whitelist of value types can be observed. + if (!canObserve(target)) { + return target + } + const handlers = collectionTypes.has(target.constructor) + ? collectionHandlers + : baseHandlers + observed = new Proxy(target, handlers) + toProxy.set(target, observed) + toRaw.set(observed, target) + targetMap.set(target, new Map()) + return observed +} + +export function autorun( + fn: Function, + options: AutorunOptions = EMPTY_OBJ +): Autorun { + if ((fn as Autorun).isAutorun) { + fn = (fn as Autorun).raw + } + const runner = createAutorun(fn, options) + if (!options.lazy) { + runner() + } + return runner +} + +export function stop(runner: Autorun) { + if (runner.active) { + cleanup(runner) + runner.active = false + } +} + +export function isObservable(value: any): boolean { + return observedToRaw.has(value) || immutableToRaw.has(value) +} + +export function isImmutable(value: any): boolean { + return immutableToRaw.has(value) +} + +export function unwrap(observed: T): T { + return observedToRaw.get(observed) || immutableToRaw.get(observed) || observed +} + +export function markImmutable(value: T): T { + immutableValues.add(value) + return value +} + +export function markNonReactive(value: T): T { + nonReactiveValues.add(value) + return value +} diff --git a/packages/observer/src/lock.ts b/packages/observer/src/lock.ts new file mode 100644 index 0000000000..417526be36 --- /dev/null +++ b/packages/observer/src/lock.ts @@ -0,0 +1,10 @@ +// global immutability lock +export let LOCKED = true + +export function lock() { + LOCKED = true +} + +export function unlock() { + LOCKED = false +} diff --git a/packages/observer/src/operations.ts b/packages/observer/src/operations.ts new file mode 100644 index 0000000000..9f6ac5264c --- /dev/null +++ b/packages/observer/src/operations.ts @@ -0,0 +1,11 @@ +export const enum OperationTypes { + // using literal strings instead of numbers so that it's easier to inspect + // debugger events + SET = 'set', + ADD = 'add', + DELETE = 'delete', + CLEAR = 'clear', + GET = 'get', + HAS = 'has', + ITERATE = 'iterate' +} diff --git a/packages/observer/src/state.ts b/packages/observer/src/state.ts new file mode 100644 index 0000000000..9efbe37337 --- /dev/null +++ b/packages/observer/src/state.ts @@ -0,0 +1,20 @@ +import { Autorun } from './autorun' + +// The main WeakMap that stores {target -> key -> dep} connections. +// Conceptually, it's easier to think of a dependency as a Dep class +// which maintains a Set of subscribers, but we simply store them as +// raw Sets to reduce memory overhead. +export type Dep = Set +export type KeyToDepMap = Map +export const targetMap: WeakMap = new WeakMap() + +// WeakMaps that store {raw <-> observed} pairs. +export const rawToObserved: WeakMap = new WeakMap() +export const observedToRaw: WeakMap = new WeakMap() +export const rawToImmutable: WeakMap = new WeakMap() +export const immutableToRaw: WeakMap = new WeakMap() + +// WeakSets for values that are marked immutable or non-reactive during +// observable creation. +export const immutableValues: WeakSet = new WeakSet() +export const nonReactiveValues: WeakSet = new WeakSet() diff --git a/packages/runtime-dom/.npmignore b/packages/runtime-dom/.npmignore new file mode 100644 index 0000000000..aa5ee6263b --- /dev/null +++ b/packages/runtime-dom/.npmignore @@ -0,0 +1,3 @@ +__tests__/ +__mocks__/ +dist/packages diff --git a/packages/runtime-dom/README.md b/packages/runtime-dom/README.md new file mode 100644 index 0000000000..e351b743e7 --- /dev/null +++ b/packages/runtime-dom/README.md @@ -0,0 +1,21 @@ +# @vue/runtime-dom + +``` js +import { h, render, Component } from '@vue/runtime-dom' + +class App extends Component { + data () { + return { + msg: 'Hello World!' + } + } + render () { + return h('div', this.msg) + } +} + +render( + h(App), + document.getElementById('app') +) +``` diff --git a/packages/runtime-dom/index.js b/packages/runtime-dom/index.js new file mode 100644 index 0000000000..cdc29b2a87 --- /dev/null +++ b/packages/runtime-dom/index.js @@ -0,0 +1,7 @@ +'use strict' + +if (process.env.NODE_ENV === 'production') { + module.exports = require('./dist/runtime-dom.cjs.prod.js') +} else { + module.exports = require('./dist/runtime-dom.cjs.js') +} diff --git a/packages/runtime-dom/package.json b/packages/runtime-dom/package.json new file mode 100644 index 0000000000..4277265e7a --- /dev/null +++ b/packages/runtime-dom/package.json @@ -0,0 +1,30 @@ +{ + "name": "@vue/runtime-dom", + "version": "3.0.0-alpha.1", + "description": "@vue/runtime-dom", + "main": "index.js", + "module": "dist/runtime-dom.esm.js", + "typings": "dist/index.d.ts", + "unpkg": "dist/runtime-dom.umd.js", + "buildOptions": { + "name": "Vue", + "formats": ["esm", "cjs", "umd", "esm-browser"] + }, + "repository": { + "type": "git", + "url": "git+https://github.com/vuejs/vue.git" + }, + "keywords": [ + "vue" + ], + "author": "Evan You", + "license": "MIT", + "bugs": { + "url": "https://github.com/vuejs/vue/issues" + }, + "homepage": "https://github.com/vuejs/vue/tree/dev/packages/runtime-dom#readme", + "dependencies": { + "@vue/core": "3.0.0-alpha.1", + "@vue/scheduler": "3.0.0-alpha.1" + } +} diff --git a/packages/runtime-dom/src/index.ts b/packages/runtime-dom/src/index.ts new file mode 100644 index 0000000000..cef0a3e0e9 --- /dev/null +++ b/packages/runtime-dom/src/index.ts @@ -0,0 +1,39 @@ +import { + h, + cloneVNode, + createPortal, + Component, + createRenderer +} from '@vue/core' + +import { queueJob, nextTick } from '@vue/scheduler' + +import { nodeOps } from './nodeOps' +import { patchData } from './patchData' +import { teardownVNode } from './teardownVNode' + +const { render } = createRenderer({ + queueJob, + nodeOps, + patchData, + teardownVNode +}) + +// important: inline the definition for nextTick +const publicNextTick = nextTick as (fn: Function) => Promise + +export { h, cloneVNode, createPortal, Component, render, publicNextTick as nextTick } + +// also expose observer API +export { + autorun, + stop, + observable, + immutable, + computed, + isObservable, + isImmutable, + markImmutable, + markNonReactive, + unwrap +} from '@vue/core' diff --git a/packages/runtime-dom/src/modules/attrs.ts b/packages/runtime-dom/src/modules/attrs.ts new file mode 100644 index 0000000000..d32f6608e0 --- /dev/null +++ b/packages/runtime-dom/src/modules/attrs.ts @@ -0,0 +1,31 @@ +export function patchAttr( + el: Element, + key: string, + value: any, + isSVG: boolean +) { + // isSVG short-circuits isXlink check + if (isSVG && isXlink(key)) { + if (value == null) { + el.removeAttributeNS(xlinkNS, getXlinkProp(key)) + } else { + el.setAttributeNS(xlinkNS, key, value) + } + } else { + if (value == null) { + el.removeAttribute(key) + } else { + el.setAttribute(key, value) + } + } +} + +const xlinkNS = 'http://www.w3.org/1999/xlink' + +function isXlink(name: string): boolean { + return name.charAt(5) === ':' && name.slice(0, 5) === 'xlink' +} + +function getXlinkProp(name: string): string { + return isXlink(name) ? name.slice(6, name.length) : '' +} diff --git a/packages/runtime-dom/src/modules/class.ts b/packages/runtime-dom/src/modules/class.ts new file mode 100644 index 0000000000..f953d46f69 --- /dev/null +++ b/packages/runtime-dom/src/modules/class.ts @@ -0,0 +1,29 @@ +// compiler should normlaize class + :class bindings on the same element +// into a single binding ['staticClass', dynamic] + +export function patchClass(el: Element, value: any, isSVG: boolean) { + // directly setting className should be faster than setAttribute in theory + if (isSVG) { + el.setAttribute('class', normalizeClass(value)) + } else { + el.className = normalizeClass(value) + } +} + +function normalizeClass(value: any): string { + let res = '' + if (typeof value === 'string') { + res = value + } else if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) { + res += normalizeClass(value[i]) + ' ' + } + } else if (typeof value === 'object') { + for (const name in value) { + if (value[name]) { + res += name + ' ' + } + } + } + return res.trim() +} diff --git a/packages/runtime-dom/src/modules/events.ts b/packages/runtime-dom/src/modules/events.ts new file mode 100644 index 0000000000..452ff00140 --- /dev/null +++ b/packages/runtime-dom/src/modules/events.ts @@ -0,0 +1,142 @@ +const delegateRE = /^(?:click|dblclick|submit|(?:key|mouse|touch).*)$/ + +type EventValue = Function | Function[] +type TargetRef = { el: Element | Document } + +export function patchEvent( + el: Element, + name: string, + prevValue: EventValue | null, + nextValue: EventValue | null +) { + if (delegateRE.test(name)) { + handleDelegatedEvent(el, name, nextValue) + } else { + handleNormalEvent(el, name, prevValue, nextValue) + } +} + +const eventCounts: Record = {} +const attachedGlobalHandlers: Record = {} + +export function handleDelegatedEvent( + el: any, + name: string, + value: EventValue | null +) { + const count = eventCounts[name] + let store = el.__events + if (value) { + if (!count) { + attachGlobalHandler(name) + } + if (!store) { + store = el.__events = {} + } + if (!store[name]) { + eventCounts[name]++ + } + store[name] = value + } else if (store && store[name]) { + eventCounts[name]-- + store[name] = null + if (count === 1) { + removeGlobalHandler(name) + } + } +} + +function attachGlobalHandler(name: string) { + const handler = (attachedGlobalHandlers[name] = (e: Event) => { + const { type } = e + const isClick = type === 'click' || type === 'dblclick' + if (isClick && (e as MouseEvent).button !== 0) { + e.stopPropagation() + return false + } + e.stopPropagation = stopPropagation + const targetRef: TargetRef = { el: document } + Object.defineProperty(e, 'currentTarget', { + configurable: true, + get() { + return targetRef.el + } + }) + dispatchEvent(e, name, isClick, targetRef) + }) + document.addEventListener(name, handler) + eventCounts[name] = 0 +} + +function stopPropagation() { + this.cancelBubble = true + if (!this.immediatePropagationStopped) { + this.stopImmediatePropagation() + } +} + +function dispatchEvent( + e: Event, + name: string, + isClick: boolean, + targetRef: TargetRef +) { + let el = e.target as any + while (el != null) { + // Don't process clicks on disabled elements + if (isClick && el.disabled) { + break + } + const store = el.__events + if (store) { + const value = store[name] + if (value) { + targetRef.el = el + invokeEvents(e, value) + if (e.cancelBubble) { + break + } + } + } + el = el.parentNode + } +} + +function invokeEvents(e: Event, value: EventValue) { + if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) { + value[i](e) + } + } else { + value(e) + } +} + +function removeGlobalHandler(name: string) { + document.removeEventListener(name, attachedGlobalHandlers[name] as any) + eventCounts[name] = 0 +} + +function handleNormalEvent(el: Element, name: string, prev: any, next: any) { + const invoker = prev && prev.invoker + if (next) { + if (invoker) { + prev.invoker = null + invoker.value = next + next.invoker = invoker + } else { + el.addEventListener(name, createInvoker(next)) + } + } else if (invoker) { + el.removeEventListener(name, invoker) + } +} + +function createInvoker(value: any) { + const invoker = ((e: Event) => { + invokeEvents(e, invoker.value) + }) as any + invoker.value = value + value.invoker = invoker + return invoker +} diff --git a/packages/runtime-dom/src/modules/props.ts b/packages/runtime-dom/src/modules/props.ts new file mode 100644 index 0000000000..f2e6bd7385 --- /dev/null +++ b/packages/runtime-dom/src/modules/props.ts @@ -0,0 +1,18 @@ +import { VNode, ChildrenFlags } from '@vue/core' + +export function patchDOMProp( + el: any, + key: string, + value: any, + prevVNode: VNode, + unmountChildren: any +) { + if (key === 'innerHTML' || key === 'textContent') { + if (prevVNode && prevVNode.children) { + unmountChildren(prevVNode.children, prevVNode.childFlags) + prevVNode.children = null + prevVNode.childFlags = ChildrenFlags.NO_CHILDREN + } + } + el[key] = value +} diff --git a/packages/runtime-dom/src/modules/style.ts b/packages/runtime-dom/src/modules/style.ts new file mode 100644 index 0000000000..1652949d44 --- /dev/null +++ b/packages/runtime-dom/src/modules/style.ts @@ -0,0 +1,54 @@ +import { isObservable } from '@vue/core' + +// style properties that should NOT have "px" added when numeric +const nonNumericRE = /acit|ex(?:s|g|n|p|$)|rph|ows|mnc|ntw|ine[ch]|zoo|^ord/i + +export function patchStyle(el: any, prev: any, next: any, data: any) { + // If next is observed, the user is likely to mutate the style object. + // We need to normalize + clone it and replace data.style with the clone. + if (isObservable(next)) { + data.style = normalizeStyle(next) + } + + const { style } = el + if (!next) { + el.removeAttribute('style') + } else if (typeof next === 'string') { + style.cssText = next + } else { + // TODO: warn invalid value in dev + next = normalizeStyle(next) + for (const key in next) { + let value = next[key] + if (typeof value === 'number' && !nonNumericRE.test(key)) { + value = value + 'px' + } + style.setProperty(key, value) + } + if (prev && typeof prev !== 'string') { + prev = normalizeStyle(prev) + for (const key in prev) { + if (!next[key]) { + style.setProperty(key, '') + } + } + } + } +} + +function normalizeStyle(value: any): Record | void { + if (value && typeof value === 'object') { + return value + } else if (Array.isArray(value)) { + const res: Record = {} + for (let i = 0; i < value.length; i++) { + const normalized = normalizeStyle(value[i]) + if (normalized) { + for (const key in normalized) { + res[key] = normalized[key] + } + } + } + return res + } +} diff --git a/packages/runtime-dom/src/nodeOps.ts b/packages/runtime-dom/src/nodeOps.ts new file mode 100644 index 0000000000..df17fcd3eb --- /dev/null +++ b/packages/runtime-dom/src/nodeOps.ts @@ -0,0 +1,39 @@ +const svgNS = 'http://www.w3.org/2000/svg' + +export const nodeOps = { + createElement: (tag: string, isSVG?: boolean): Element => + isSVG ? document.createElementNS(svgNS, tag) : document.createElement(tag), + + createText: (text: string): Text => document.createTextNode(text), + + setText: (node: Text, text: string) => { + node.nodeValue = text + }, + + appendChild: (parent: Node, child: Node) => { + parent.appendChild(child) + }, + + insertBefore: (parent: Node, child: Node, ref: Node) => { + parent.insertBefore(child, ref) + }, + + replaceChild: (parent: Node, oldChild: Node, newChild: Node) => { + parent.replaceChild(newChild, oldChild) + }, + + removeChild: (parent: Node, child: Node) => { + parent.removeChild(child) + }, + + clearContent: (node: Node) => { + node.textContent = '' + }, + + parentNode: (node: Node): Node | null => node.parentNode, + + nextSibling: (node: Node): Node | null => node.nextSibling, + + querySelector: (selector: string): Node | null => + document.querySelector(selector) +} diff --git a/packages/runtime-dom/src/patchData.ts b/packages/runtime-dom/src/patchData.ts new file mode 100644 index 0000000000..884d5c436c --- /dev/null +++ b/packages/runtime-dom/src/patchData.ts @@ -0,0 +1,42 @@ +import { VNode } from '@vue/core' +import { patchClass } from './modules/class' +import { patchStyle } from './modules/style' +import { patchAttr } from './modules/attrs' +import { patchDOMProp } from './modules/props' +import { patchEvent } from './modules/events' + +export function patchData( + el: Element, + key: string, + prevValue: any, + nextValue: any, + prevVNode: VNode, + nextVNode: VNode, + isSVG: boolean, + unmountChildren: any +) { + switch (key) { + // special + case 'class': + patchClass(el, nextValue, isSVG) + break + case 'style': + patchStyle(el, prevValue, nextValue, nextVNode.data) + break + default: + if (key.startsWith('on')) { + patchEvent(el, key.toLowerCase().slice(2), prevValue, nextValue) + } else if (key.startsWith('domProps')) { + patchDOMProp( + el, + key[8].toLowerCase() + key.slice(9), + nextValue, + prevVNode, + unmountChildren + ) + } else { + patchAttr(el, key, nextValue, isSVG) + } + break + } +} diff --git a/packages/runtime-dom/src/teardownVNode.ts b/packages/runtime-dom/src/teardownVNode.ts new file mode 100644 index 0000000000..e1302c5e5e --- /dev/null +++ b/packages/runtime-dom/src/teardownVNode.ts @@ -0,0 +1,13 @@ +import { VNode } from '@vue/core' +import { handleDelegatedEvent } from './modules/events' + +export function teardownVNode(vnode: VNode) { + const { el, data } = vnode + if (data != null) { + for (const key in data) { + if (key.startsWith('on')) { + handleDelegatedEvent(el, key.toLowerCase().slice(2), null) + } + } + } +} diff --git a/packages/scheduler/.npmignore b/packages/scheduler/.npmignore new file mode 100644 index 0000000000..bb5c8a541b --- /dev/null +++ b/packages/scheduler/.npmignore @@ -0,0 +1,3 @@ +__tests__/ +__mocks__/ +dist/packages \ No newline at end of file diff --git a/packages/scheduler/README.md b/packages/scheduler/README.md new file mode 100644 index 0000000000..826934e7e1 --- /dev/null +++ b/packages/scheduler/README.md @@ -0,0 +1,3 @@ +# @vue/scheduler + +> This package is published only for typing and building custom renderers. It is NOT meant to be used in applications. diff --git a/packages/scheduler/index.js b/packages/scheduler/index.js new file mode 100644 index 0000000000..9a0883bfca --- /dev/null +++ b/packages/scheduler/index.js @@ -0,0 +1,7 @@ +'use strict' + +if (process.env.NODE_ENV === 'production') { + module.exports = require('./dist/scheduler.cjs.prod.js') +} else { + module.exports = require('./dist/scheduler.cjs.js') +} diff --git a/packages/scheduler/package.json b/packages/scheduler/package.json new file mode 100644 index 0000000000..b65d87c852 --- /dev/null +++ b/packages/scheduler/package.json @@ -0,0 +1,21 @@ +{ + "name": "@vue/scheduler", + "version": "3.0.0-alpha.1", + "description": "@vue/scheduler", + "main": "index.js", + "module": "dist/scheduler.esm.js", + "typings": "dist/index.d.ts", + "repository": { + "type": "git", + "url": "git+https://github.com/vuejs/vue.git" + }, + "keywords": [ + "vue" + ], + "author": "Evan You", + "license": "MIT", + "bugs": { + "url": "https://github.com/vuejs/vue/issues" + }, + "homepage": "https://github.com/vuejs/vue/tree/dev/packages/scheduler#readme" +} diff --git a/packages/scheduler/src/index.ts b/packages/scheduler/src/index.ts new file mode 100644 index 0000000000..31f52297c7 --- /dev/null +++ b/packages/scheduler/src/index.ts @@ -0,0 +1,40 @@ +const queue: Array<() => void> = [] +const postFlushCbs: Array<() => void> = [] +const p = Promise.resolve() +let flushing = false + +export function nextTick(fn: () => void) { + p.then(fn) +} + +export function queueJob(job: () => void, postFlushCb?: () => void) { + if (queue.indexOf(job) === -1) { + if (flushing) { + job() + } else { + queue.push(job) + } + } + if (postFlushCb) { + queuePostFlushCb(postFlushCb) + } + if (!flushing) { + nextTick(flushJobs) + } +} + +export function queuePostFlushCb(cb: () => void) { + postFlushCbs.push(cb) +} + +export function flushJobs() { + flushing = true + let job + while ((job = queue.shift())) { + job() + } + while ((job = postFlushCbs.shift())) { + job() + } + flushing = false +} diff --git a/rollup.config.js b/rollup.config.js new file mode 100644 index 0000000000..320edf5f5a --- /dev/null +++ b/rollup.config.js @@ -0,0 +1,147 @@ +const fs = require('fs') +const path = require('path') +const ts = require('rollup-plugin-typescript2') +const replace = require('rollup-plugin-replace') +const alias = require('rollup-plugin-alias') + +if (!process.env.TARGET) { + throw new Error('TARGET package must be specified via --environment flag.') +} + +const packagesDir = path.resolve(__dirname, 'packages') +const packageDir = path.resolve(packagesDir, process.env.TARGET) +const name = path.basename(packageDir) +const resolve = p => path.resolve(packageDir, p) +const pkg = require(resolve(`package.json`)) +const packageOptions = pkg.buildOptions || {} + +// build aliases dynamically +const aliasOptions = { resolve: ['.ts'] } +fs.readdirSync(packagesDir).forEach(dir => { + if (fs.statSync(path.resolve(packagesDir, dir)).isDirectory()) { + aliasOptions[`@vue/${dir}`] = path.resolve(packagesDir, `${dir}/src/index`) + } +}) +const aliasPlugin = alias(aliasOptions) + +// ensure TS checks only once for each build +let hasTSChecked = false + +const configs = { + esm: { + file: resolve(`dist/${name}.esm.js`), + format: `es` + }, + cjs: { + file: resolve(`dist/${name}.cjs.js`), + format: `cjs` + }, + umd: { + file: resolve(`dist/${name}.umd.js`), + format: `umd` + }, + 'esm-browser': { + file: resolve(`dist/${name}.esm-browser.js`), + format: `es` + } +} + +const defaultFormats = ['esm', 'cjs'] +const inlineFromats = process.env.FORMATS && process.env.FORMATS.split(',') +const packageFormats = inlineFromats || packageOptions.formats || defaultFormats +const packageConfigs = packageFormats.map(format => createConfig(configs[format])) + +if (process.env.NODE_ENV === 'production') { + packageFormats.forEach(format => { + if (format === 'cjs') { + packageConfigs.push(createProductionConfig(format)) + } + if (format === 'umd' || format === 'esm-browser') { + packageConfigs.push(createMinifiedConfig(format)) + } + }) +} + +module.exports = packageConfigs + +function createConfig(output, plugins = []) { + const isProductionBuild = /\.prod\.js$/.test(output.file) + const isUMDBuild = /\.umd(\.prod)?\.js$/.test(output.file) + const isBunlderESMBuild = /\.esm\.js$/.test(output.file) + const isBrowserESMBuild = /esm-browser(\.prod)?\.js$/.test(output.file) + + if (isUMDBuild) { + output.name = packageOptions.name + } + + const tsPlugin = ts({ + check: process.env.NODE_ENV === 'production' && !hasTSChecked, + tsconfig: path.resolve(__dirname, 'tsconfig.json'), + cacheRoot: path.resolve(__dirname, 'node_modules/.rts2_cache'), + tsconfigOverride: { + compilerOptions: { + declaration: process.env.NODE_ENV === 'production' && !hasTSChecked + } + } + }) + // we only need to check TS and generate declarations once for each build. + // it also seems to run into weird issues when checking multiple times + // during a single build. + hasTSChecked = true + + return { + input: resolve(`src/index.ts`), + // UMD and Browser ESM builds inlines everything so that they can be + // used alone. + external: isUMDBuild || isBrowserESMBuild + ? [] + : Object.keys(aliasOptions), + plugins: [ + tsPlugin, + aliasPlugin, + createReplacePlugin(isProductionBuild, isBunlderESMBuild), + ...plugins + ], + output, + onwarn: (msg, warn) => { + if (!/Circular/.test(msg)) { + warn(msg) + } + } + } +} + +function createReplacePlugin(isProduction, isBunlderESMBuild) { + return replace({ + __DEV__: isBunlderESMBuild + // preserve to be handled by bundlers + ? `process.env.NODE_ENV !== 'production'` + // hard coded dev/prod builds + : !isProduction, + // compatibility builds + __COMPAT__: !!process.env.COMPAT + }) +} + +function createProductionConfig(format) { + return createConfig({ + file: resolve(`dist/${name}.${format}.prod.js`), + format: /^esm/.test(format) ? 'es' : format + }) +} + +function createMinifiedConfig(format) { + const { terser } = require('rollup-plugin-terser') + const isESM = /^esm/.test(format) + return createConfig( + { + file: resolve(`dist/${name}.${format}.prod.js`), + format: isESM ? 'es' : format + }, + [ + terser({ + module: isESM + }) + ] + ) +} diff --git a/scripts/bootstrap.js b/scripts/bootstrap.js new file mode 100644 index 0000000000..21098b3fb4 --- /dev/null +++ b/scripts/bootstrap.js @@ -0,0 +1,75 @@ +// create package.json, README, etc. for packages that don't have them yet + +const args = require('minimist')(process.argv.slice(2)) +const fs = require('fs') +const path = require('path') +const baseVersion = require('../lerna.json').version + +const packagesDir = path.resolve(__dirname, '../packages') +const files = fs.readdirSync(packagesDir) + +files.forEach(shortName => { + if (!fs.statSync(path.join(packagesDir, shortName)).isDirectory()) { + return + } + + const name = shortName === `vue` ? shortName : `@vue/${shortName}` + const pkgPath = path.join(packagesDir, shortName, `package.json`) + if (args.force || !fs.existsSync(pkgPath)) { + const json = { + name, + version: baseVersion, + description: name, + main: 'index.js', + module: `dist/${shortName}.esm.js`, + typings: 'dist/index.d.ts', + repository: { + type: 'git', + url: 'git+https://github.com/vuejs/vue.git' + }, + keywords: ['vue'], + author: 'Evan You', + license: 'MIT', + bugs: { + url: 'https://github.com/vuejs/vue/issues' + }, + homepage: `https://github.com/vuejs/vue/tree/dev/packages/${shortName}#readme` + } + fs.writeFileSync(pkgPath, JSON.stringify(json, null, 2)) + } + + const readmePath = path.join(packagesDir, shortName, `README.md`) + if (args.force || !fs.existsSync(readmePath)) { + fs.writeFileSync(readmePath, `# ${name}`) + } + + const npmIgnorePath = path.join(packagesDir, shortName, `.npmignore`) + if (args.force || !fs.existsSync(npmIgnorePath)) { + fs.writeFileSync(npmIgnorePath, `__tests__/\n__mocks__/\ndist/packages`) + } + + const srcDir = path.join(packagesDir, shortName, `src`) + const indexPath = path.join(packagesDir, shortName, `src/index.ts`) + if (args.force || !fs.existsSync(indexPath)) { + if (!fs.existsSync(srcDir)) { + fs.mkdirSync(srcDir) + } + fs.writeFileSync(indexPath, ``) + } + + const nodeIndexPath = path.join(packagesDir, shortName, 'index.js') + if (args.force || !fs.existsSync(nodeIndexPath)) { + fs.writeFileSync( + nodeIndexPath, + ` +'use strict' + +if (process.env.NODE_ENV === 'production') { + module.exports = require('./dist/${shortName}.cjs.prod.js') +} else { + module.exports = require('./dist/${shortName}.cjs.js') +} + `.trim() + '\n' + ) + } +}) diff --git a/scripts/build.js b/scripts/build.js new file mode 100644 index 0000000000..98c97399d9 --- /dev/null +++ b/scripts/build.js @@ -0,0 +1,70 @@ +const fs = require('fs-extra') +const path = require('path') +const zlib = require('zlib') +const chalk = require('chalk') +const execa = require('execa') +const dts = require('dts-bundle') +const { targets, fuzzyMatchTarget } = require('./utils') + +const target = process.argv[2] + +;(async () => { + if (!target) { + await buildAll(targets) + checkAllSizes(targets) + } else { + await buildAll(fuzzyMatchTarget(target)) + checkAllSizes(fuzzyMatchTarget(target)) + } +})() + +async function buildAll (targets) { + for (const target of targets) { + await build(target) + } +} + +async function build (target) { + const pkgDir = path.resolve(`packages/${target}`) + + await fs.remove(`${pkgDir}/dist`) + + await execa('rollup', [ + '-c', + '--environment', + `NODE_ENV:production,TARGET:${target}` + ], { stdio: 'inherit' }) + + const dtsOptions = { + name: target === 'vue' ? target : `@vue/${target}`, + main: `${pkgDir}/dist/packages/${target}/src/index.d.ts`, + out: `${pkgDir}/dist/index.d.ts` + } + dts.bundle(dtsOptions) + console.log() + console.log(chalk.blue(chalk.bold(`generated typings at ${dtsOptions.out}`))) + + await fs.remove(`${pkgDir}/dist/packages`) +} + +function checkAllSizes (targets) { + console.log() + for (const target of targets) { + checkSize(target) + } + console.log() +} + +function checkSize (target) { + const pkgDir = path.resolve(`packages/${target}`) + const esmProdBuild = `${pkgDir}/dist/${target}.esm-browser.prod.js` + if (fs.existsSync(esmProdBuild)) { + const file = fs.readFileSync(esmProdBuild) + const minSize = (file.length / 1024).toFixed(2) + 'kb' + const gzipped = zlib.gzipSync(file) + const gzipSize = (gzipped.length / 1024).toFixed(2) + 'kb' + console.log(`${ + chalk.gray(chalk.bold(target)) + } min:${minSize} / gzip:${gzipSize}`) + } +} diff --git a/scripts/dev.js b/scripts/dev.js new file mode 100644 index 0000000000..7f71703b51 --- /dev/null +++ b/scripts/dev.js @@ -0,0 +1,25 @@ +// Run Rollup in watch mode for a single package for development. +// Only the ES modules format will be generated, as it is expected to be tested +// in a modern browser using