diff --git a/.babelrc b/.babelrc new file mode 100644 index 00000000000..985841fe86c --- /dev/null +++ b/.babelrc @@ -0,0 +1,10 @@ +{ + "env": { + "development": { + "presets": ["es2015", "stage-2"] + }, + "production": { + "presets": ["es2015-rollup", "stage-2"] + } + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000000..866e0fe96b8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.DS_Store +node_modules +npm-debug.log +explorations +TODOs.md diff --git a/build/build.js b/build/build.js new file mode 100644 index 00000000000..5e77a388f3a --- /dev/null +++ b/build/build.js @@ -0,0 +1,124 @@ +var fs = require('fs') +var zlib = require('zlib') +var rollup = require('rollup') +var uglify = require('uglify-js') +var babel = require('rollup-plugin-babel') +var node = require('rollup-plugin-node-resolve') +var commonjs = require('rollup-plugin-commonjs') +var replace = require('rollup-plugin-replace') +var version = process.env.VERSION || require('../package.json').version + +var banner = + '/*!\n' + + ' * Vue.js v' + version + '\n' + + ' * (c) ' + new Date().getFullYear() + ' Evan You\n' + + ' * Released under the MIT License.\n' + + ' */' + +// update main file +var main = fs + .readFileSync('src/index.js', 'utf-8') + .replace(/Vue\.version = '[\d\.]+'/, "Vue.version = '" + version + "'") +fs.writeFileSync('src/index.js', main) + +var plugins = [ + node(), + commonjs({ + include: 'node_modules/**' + }), + babel({ + exclude: 'node_modules/**' + }) +] + +// CommonJS build. +// this is used as the "main" field in package.json +// and used by bundlers like Webpack and Browserify. +rollup.rollup({ + entry: 'src/index.js', + plugins: plugins +}) +.then(function (bundle) { + return write('dist/vue.common.js', bundle.generate({ + format: 'cjs', + banner: banner + }).code) +}) +// Standalone Dev Build +.then(function () { + return rollup.rollup({ + entry: 'src/index.js', + plugins: [ + replace({ + 'process.env.NODE_ENV': "'development'" + }) + ].concat(plugins) + }) + .then(function (bundle) { + return write('dist/vue.js', bundle.generate({ + format: 'umd', + banner: banner, + moduleName: 'Vue' + }).code) + }) +}) +.then(function () { + // Standalone Production Build + return rollup.rollup({ + entry: 'src/index.js', + plugins: [ + replace({ + 'process.env.NODE_ENV': "'production'" + }) + ].concat(plugins) + }) + .then(function (bundle) { + var code = bundle.generate({ + format: 'umd', + moduleName: 'Vue' + }).code + var minified = banner + '\n' + uglify.minify(code, { + fromString: true, + output: { + ascii_only: true + } + }).code + return write('dist/vue.min.js', minified) + }) + .then(zip) +}) +.catch(logError) + +function write (dest, code) { + return new Promise(function (resolve, reject) { + fs.writeFile(dest, code, function (err) { + if (err) return reject(err) + console.log(blue(dest) + ' ' + getSize(code)) + resolve() + }) + }) +} + +function zip () { + return new Promise(function (resolve, reject) { + fs.readFile('dist/vue.min.js', function (err, buf) { + if (err) return reject(err) + zlib.gzip(buf, function (err, buf) { + if (err) return reject(err) + write('dist/vue.min.js.gz', buf).then(resolve) + }) + }) + }) +} + +function getSize (code) { + return (code.length / 1024).toFixed(2) + 'kb' +} + +function logError (e) { + console.log(e) +} + +function blue (str) { + return '\x1b[1m\x1b[34m' + str + '\x1b[39m\x1b[22m' +} diff --git a/package.json b/package.json new file mode 100644 index 00000000000..4e1ac4cef33 --- /dev/null +++ b/package.json @@ -0,0 +1,38 @@ +{ + "name": "vue-lite", + "version": "1.0.0", + "description": "Lighter-weight Vue on virtual dom", + "main": "index.js", + "scripts": { + "dev": "webpack --watch", + "test": "mocha", + "build": "NODE_ENV=production node build/build.js" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/vuejs/vue-lite.git" + }, + "keywords": [ + "vue" + ], + "author": "Evan You", + "license": "MIT", + "bugs": { + "url": "https://github.com/vuejs/vue-lite/issues" + }, + "homepage": "https://github.com/vuejs/vue-lite#readme", + "devDependencies": { + "babel-core": "^6.0.0", + "babel-loader": "^6.0.0", + "babel-preset-es2015": "^6.0.0", + "babel-preset-es2015-rollup": "^1.1.1", + "babel-preset-stage-2": "^6.0.0", + "rollup": "^0.25.8", + "rollup-plugin-babel": "^2.4.0", + "rollup-plugin-commonjs": "^2.2.1", + "rollup-plugin-node-resolve": "^1.5.0", + "rollup-plugin-replace": "^1.1.0", + "uglify-js": "^2.6.2", + "webpack": "^1.12.14" + } +} diff --git a/src/compiler/codegen.js b/src/compiler/codegen.js new file mode 100644 index 00000000000..501ce0aa7fc --- /dev/null +++ b/src/compiler/codegen.js @@ -0,0 +1,129 @@ +import { parseText } from './text-parser' + +const bindRE = /^:|^v-bind:/ +const onRE = /^@|^v-on:/ +const mustUsePropsRE = /^(value|selected|checked|muted)$/ + +export function generate (ast) { + const code = genElement(ast) + return new Function (`with (this) { return ${code}}`) +} + +function genElement (el, key) { + let exp + if (exp = getAttr(el, 'v-for')) { + return genFor(el, exp) + } else if (exp = getAttr(el, 'v-if')) { + return genIf(el, exp) + } else if (el.tag === 'template') { + return genChildren(el) + } else { + return `__h__('${ el.tag }', ${ genData(el, key) }, ${ genChildren(el) })` + } +} + +function genIf (el, exp) { + return `(${ exp }) ? ${ genElement(el) } : ''` +} + +function genFor (el, exp) { + const inMatch = exp.match(/([a-zA-Z_][\w]*)\s+(?:in|of)\s+(.*)/) + if (!inMatch) { + throw new Error('Invalid v-for expression: '+ exp) + } + const alias = inMatch[1].trim() + exp = inMatch[2].trim() + const key = el.attrsMap['track-by'] || 'undefined' + return `(${ exp }).map(function (${ alias }, $index) {return ${ genElement(el, key) }})` +} + +function genData (el, key) { + if (!el.attrs.length) { + return '{}' + } + let data = key ? `{key:${ key },` : `{` + if (el.attrsMap[':class'] || el.attrsMap['class']) { + data += `class: _renderClass(${ el.attrsMap[':class'] }, "${ el.attrsMap['class'] || '' }"),` + } + let attrs = `attrs:{` + let props = `props:{` + let hasAttrs = false + let hasProps = false + for (let i = 0, l = el.attrs.length; i < l; i++) { + let attr = el.attrs[i] + let name = attr.name + if (bindRE.test(name)) { + name = name.replace(bindRE, '') + if (name === 'class') { + continue + } else if (name === 'style') { + data += `style: ${ attr.value },` + } else if (mustUsePropsRE.test(name)) { + hasProps = true + props += `"${ name }": (${ attr.value }),` + } else { + hasAttrs = true + attrs += `"${ name }": (${ attr.value }),` + } + } else if (onRE.test(name)) { + name = name.replace(onRE, '') + // TODO + } else if (name !== 'class') { + hasAttrs = true + attrs += `"${ name }": (${ JSON.stringify(attr.value) }),` + } + } + if (hasAttrs) { + data += attrs.slice(0, -1) + '},' + } + if (hasProps) { + data += props.slice(0, -1) + '},' + } + return data.replace(/,$/, '') + '}' +} + +function genChildren (el) { + if (!el.children.length) { + return 'undefined' + } + return '__flatten__([' + el.children.map(genNode).join(',') + '])' +} + +function genNode (node) { + if (node.tag) { + return genElement(node) + } else { + return genText(node) + } +} + +function genText (text) { + if (text === ' ') { + return '" "' + } else { + const exp = parseText(text) + if (exp) { + return 'String(' + escapeNewlines(exp) + ')' + } else { + return escapeNewlines(JSON.stringify(text)) + } + } +} + +function escapeNewlines (str) { + return str.replace(/\n/g, '\\n') +} + +function getAttr (el, attr) { + let val + if (val = el.attrsMap[attr]) { + el.attrsMap[attr] = null + for (let i = 0, l = el.attrs.length; i < l; i++) { + if (el.attrs[i].name === attr) { + el.attrs.splice(i, 1) + break + } + } + } + return val +} diff --git a/src/compiler/html-parser.js b/src/compiler/html-parser.js new file mode 100644 index 00000000000..8ad1b4b0f75 --- /dev/null +++ b/src/compiler/html-parser.js @@ -0,0 +1,389 @@ +/** + * Convert HTML string to AST + * + * @param {String} html + * @return {Object} + */ + +export function parse (html) { + let root + let currentParent + let stack = [] + HTMLParser(html, { + html5: true, + start (tag, attrs, unary) { + let element = { + tag, + attrs, + attrsMap: makeAttrsMap(attrs), + parent: currentParent, + children: [] + } + if (!root) { + root = element + } + if (currentParent) { + currentParent.children.push(element) + } + if (!unary) { + currentParent = element + stack.push(element) + } + }, + end () { + stack.length -= 1 + currentParent = stack[stack.length - 1] + }, + chars (text) { + text = currentParent.tag === 'pre' + ? text + : text.trim() ? text : ' ' + currentParent.children.push(text) + }, + comment () { + // noop + } + }) + return root +} + +function makeAttrsMap (attrs) { + const map = {} + for (let i = 0, l = attrs.length; i < l; i++) { + map[attrs[i].name] = attrs[i].value + } + return map +} + +/*! + * HTML Parser By John Resig (ejohn.org) + * Modified by Juriy "kangax" Zaytsev + * Original code by Erik Arvidsson, Mozilla Public License + * http://erik.eae.net/simplehtmlparser/simplehtmlparser.js + */ + +/* + * // Use like so: + * HTMLParser(htmlString, { + * start: function(tag, attrs, unary) {}, + * end: function(tag) {}, + * chars: function(text) {}, + * comment: function(text) {} + * }); + */ + +/* global ActiveXObject, DOMDocument */ + +function makeMap(values) { + values = values.split(/,/) + var map = {} + values.forEach(function(value) { + map[value] = 1 + }) + return function(value) { + return map[value.toLowerCase()] === 1 + } +} + +// Regular Expressions for parsing tags and attributes +var singleAttrIdentifier = /([^\s"'<>\/=]+)/, + singleAttrAssign = /=/, + singleAttrAssigns = [singleAttrAssign], + singleAttrValues = [ + // attr value double quotes + /"([^"]*)"+/.source, + // attr value, single quotes + /'([^']*)'+/.source, + // attr value, no quotes + /([^\s"'=<>`]+)/.source + ], + qnameCapture = (function() { + // could use https://www.w3.org/TR/1999/REC-xml-names-19990114/#NT-QName + // but for Vue templates we can enforce a simple charset + var ncname = '[a-zA-Z_][\\w\\-\\.]*' + return '((?:' + ncname + '\\:)?' + ncname + ')' + })(), + startTagOpen = new RegExp('^<' + qnameCapture), + startTagClose = /^\s*(\/?)>/, + endTag = new RegExp('^<\\/' + qnameCapture + '[^>]*>'), + doctype = /^]+>/i + +var IS_REGEX_CAPTURING_BROKEN = false +'x'.replace(/x(.)?/g, function(m, g) { + IS_REGEX_CAPTURING_BROKEN = g === '' +}) + +// Empty Elements +var empty = makeMap('area,base,basefont,br,col,embed,frame,hr,img,input,isindex,keygen,link,meta,param,source,track,wbr') + +// Inline Elements +var inline = makeMap('a,abbr,acronym,applet,b,basefont,bdo,big,br,button,cite,code,del,dfn,em,font,i,iframe,img,input,ins,kbd,label,map,noscript,object,q,s,samp,script,select,small,span,strike,strong,sub,sup,svg,textarea,tt,u,var') + +// Elements that you can, intentionally, leave open +// (and which close themselves) +var closeSelf = makeMap('colgroup,dd,dt,li,options,p,td,tfoot,th,thead,tr,source') + +// Attributes that have their values filled in disabled='disabled' +var fillAttrs = makeMap('checked,compact,declare,defer,disabled,ismap,multiple,nohref,noresize,noshade,nowrap,readonly,selected') + +// Special Elements (can contain anything) +var special = makeMap('script,style') + +// HTML5 tags https://html.spec.whatwg.org/multipage/indices.html#elements-3 +// Phrasing Content https://html.spec.whatwg.org/multipage/dom.html#phrasing-content +var nonPhrasing = makeMap('address,article,aside,base,blockquote,body,caption,col,colgroup,dd,details,dialog,div,dl,dt,fieldset,figcaption,figure,footer,form,h1,h2,h3,h4,h5,h6,head,header,hgroup,hr,html,legend,li,menuitem,meta,optgroup,option,param,rp,rt,source,style,summary,tbody,td,tfoot,th,thead,title,tr,track') + +var reCache = {} + +function attrForHandler(handler) { + var pattern = singleAttrIdentifier.source + + '(?:\\s*(' + joinSingleAttrAssigns(handler) + ')' + + '\\s*(?:' + singleAttrValues.join('|') + '))?' + return new RegExp('^\\s*' + pattern) +} + +function joinSingleAttrAssigns(handler) { + return singleAttrAssigns.map(function(assign) { + return '(?:' + assign.source + ')' + }).join('|') +} + +export default function HTMLParser(html, handler) { + var stack = [], lastTag + var attribute = attrForHandler(handler) + var last, prevTag, nextTag + while (html) { + last = html + // Make sure we're not in a script or style element + if (!lastTag || !special(lastTag)) { + var textEnd = html.indexOf('<') + if (textEnd === 0) { + // Comment: + if (/^') + + if (commentEnd >= 0) { + if (handler.comment) { + handler.comment(html.substring(4, commentEnd)) + } + html = html.substring(commentEnd + 3) + prevTag = '' + continue + } + } + + // http://en.wikipedia.org/wiki/Conditional_comment#Downlevel-revealed_conditional_comment + if (/^') + + if (conditionalEnd >= 0) { + if (handler.comment) { + handler.comment(html.substring(2, conditionalEnd + 1), true /* non-standard */) + } + html = html.substring(conditionalEnd + 2) + prevTag = '' + continue + } + } + + // Doctype: + var doctypeMatch = html.match(doctype) + if (doctypeMatch) { + if (handler.doctype) { + handler.doctype(doctypeMatch[0]) + } + html = html.substring(doctypeMatch[0].length) + prevTag = '' + continue + } + + // End tag: + var endTagMatch = html.match(endTag) + if (endTagMatch) { + html = html.substring(endTagMatch[0].length) + endTagMatch[0].replace(endTag, parseEndTag) + prevTag = '/' + endTagMatch[1].toLowerCase() + continue + } + + // Start tag: + var startTagMatch = parseStartTag(html) + if (startTagMatch) { + html = startTagMatch.rest + handleStartTag(startTagMatch) + prevTag = startTagMatch.tagName.toLowerCase() + continue + } + } + + var text + if (textEnd >= 0) { + text = html.substring(0, textEnd) + html = html.substring(textEnd) + } + else { + text = html + html = '' + } + + // next tag + var nextTagMatch = parseStartTag(html) + if (nextTagMatch) { + nextTag = nextTagMatch.tagName + } + else { + nextTagMatch = html.match(endTag) + if (nextTagMatch) { + nextTag = '/' + nextTagMatch[1] + } + else { + nextTag = '' + } + } + + if (handler.chars) { + handler.chars(text, prevTag, nextTag) + } + prevTag = '' + + } + else { + var stackedTag = lastTag.toLowerCase() + var reStackedTag = reCache[stackedTag] || (reCache[stackedTag] = new RegExp('([\\s\\S]*?)]*>', 'i')) + + html = html.replace(reStackedTag, function(all, text) { + if (stackedTag !== 'script' && stackedTag !== 'style' && stackedTag !== 'noscript') { + text = text + .replace(//g, '$1') + .replace(//g, '$1') + } + + if (handler.chars) { + handler.chars(text) + } + + return '' + }) + + parseEndTag('', stackedTag) + } + + if (html === last) { + throw new Error('Parse Error: ' + html) + } + } + + if (!handler.partialMarkup) { + // Clean up any remaining tags + parseEndTag() + } + + function parseStartTag(input) { + var start = input.match(startTagOpen) + if (start) { + var match = { + tagName: start[1], + attrs: [] + } + input = input.slice(start[0].length) + var end, attr + while (!(end = input.match(startTagClose)) && (attr = input.match(attribute))) { + input = input.slice(attr[0].length) + match.attrs.push(attr) + } + if (end) { + match.unarySlash = end[1] + match.rest = input.slice(end[0].length) + return match + } + } + } + + function handleStartTag(match) { + var tagName = match.tagName + var unarySlash = match.unarySlash + + if (handler.html5 && lastTag === 'p' && nonPhrasing(tagName)) { + parseEndTag('', lastTag) + } + + if (!handler.html5) { + while (lastTag && inline(lastTag)) { + parseEndTag('', lastTag) + } + } + + if (closeSelf(tagName) && lastTag === tagName) { + parseEndTag('', tagName) + } + + var unary = empty(tagName) || tagName === 'html' && lastTag === 'head' || !!unarySlash + + var attrs = match.attrs.map(function(args) { + // hackish work around FF bug https://bugzilla.mozilla.org/show_bug.cgi?id=369778 + if (IS_REGEX_CAPTURING_BROKEN && args[0].indexOf('""') === -1) { + if (args[3] === '') { delete args[3] } + if (args[4] === '') { delete args[4] } + if (args[5] === '') { delete args[5] } + } + return { + name: args[1], + value: args[3] || args[4] || (args[5] && fillAttrs(args[5]) ? name : '') + } + }) + + if (!unary) { + stack.push({ tag: tagName, attrs: attrs }) + lastTag = tagName + unarySlash = '' + } + + if (handler.start) { + handler.start(tagName, attrs, unary, unarySlash) + } + } + + function parseEndTag(tag, tagName) { + var pos + + // Find the closest opened tag of the same type + if (tagName) { + var needle = tagName.toLowerCase() + for (pos = stack.length - 1; pos >= 0; pos--) { + if (stack[pos].tag.toLowerCase() === needle) { + break + } + } + } + // If no tag name is provided, clean shop + else { + pos = 0 + } + + if (pos >= 0) { + // Close all the open elements, up the stack + for (var i = stack.length - 1; i >= pos; i--) { + if (handler.end) { + handler.end(stack[i].tag, stack[i].attrs, i > pos || !tag) + } + } + + // Remove the open elements from the stack + stack.length = pos + lastTag = pos && stack[pos - 1].tag + } + else if (tagName.toLowerCase() === 'br') { + if (handler.start) { + handler.start(tagName, [], true, '') + } + } + else if (tagName.toLowerCase() === 'p') { + if (handler.start) { + handler.start(tagName, [], false, '', true) + } + if (handler.end) { + handler.end(tagName, []) + } + } + } +} diff --git a/src/compiler/index.js b/src/compiler/index.js new file mode 100644 index 00000000000..8b67fa8b3ae --- /dev/null +++ b/src/compiler/index.js @@ -0,0 +1,10 @@ +import { parse } from './html-parser' +import { generate } from './codegen' + +const cache = Object.create(null) + +export function compile (html) { + html = html.trim() + const hit = cache[html] + return hit || (cache[html] = generate(parse(html))) +} diff --git a/src/compiler/text-parser.js b/src/compiler/text-parser.js new file mode 100644 index 00000000000..16588419eec --- /dev/null +++ b/src/compiler/text-parser.js @@ -0,0 +1,27 @@ +const tagRE = /\{\{((?:.|\\n)+?)\}\}/g + +export function parseText (text) { + if (!tagRE.test(text)) { + return null + } + var tokens = [] + var lastIndex = tagRE.lastIndex = 0 + var match, index, value + /* eslint-disable no-cond-assign */ + while (match = tagRE.exec(text)) { + /* eslint-enable no-cond-assign */ + index = match.index + // push text token + if (index > lastIndex) { + tokens.push(JSON.stringify(text.slice(lastIndex, index))) + } + // tag token + value = match[1] + tokens.push('(' + match[1].trim() + ')') + lastIndex = index + match[0].length + } + if (lastIndex < text.length) { + tokens.push(JSON.stringify(text.slice(lastIndex))) + } + return tokens.join('+') +} diff --git a/src/config.js b/src/config.js new file mode 100644 index 00000000000..a0b9619be81 --- /dev/null +++ b/src/config.js @@ -0,0 +1,79 @@ +export default { + + /** + * Whether to print debug messages. + * Also enables stack trace for warnings. + * + * @type {Boolean} + */ + + debug: false, + + /** + * Whether to suppress warnings. + * + * @type {Boolean} + */ + + silent: false, + + /** + * Whether to use async rendering. + */ + + async: true, + + /** + * Whether to warn against errors caught when evaluating + * expressions. + */ + + warnExpressionErrors: true, + + /** + * Whether to allow devtools inspection. + * Disabled by default in production builds. + */ + + devtools: process.env.NODE_ENV !== 'production', + + /** + * Internal flag to indicate the delimiters have been + * changed. + * + * @type {Boolean} + */ + + _delimitersChanged: true, + + /** + * List of asset types that a component can own. + * + * @type {Array} + */ + + _assetTypes: [ + 'component', + 'directive', + 'elementDirective', + 'filter', + 'transition', + 'partial' + ], + + /** + * prop binding modes + */ + + _propBindingModes: { + ONE_WAY: 0, + TWO_WAY: 1, + ONE_TIME: 2 + }, + + /** + * Max circular updates allowed in a batcher flush cycle. + */ + + _maxUpdateCount: 100 +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 00000000000..3bba24b56f8 --- /dev/null +++ b/src/index.js @@ -0,0 +1,3 @@ +import Vue from './instance/index' + +export default Vue diff --git a/src/index.umd.js b/src/index.umd.js new file mode 100644 index 00000000000..69ae9836362 --- /dev/null +++ b/src/index.umd.js @@ -0,0 +1 @@ +module.exports = require('./index')['default'] diff --git a/src/instance/index.js b/src/instance/index.js new file mode 100644 index 00000000000..8f7028df24e --- /dev/null +++ b/src/instance/index.js @@ -0,0 +1,85 @@ +import { compile } from '../compiler/index' +import { observe } from '../observer/index' +import Watcher from '../observer/watcher' +import { h, patch } from '../vdom/index' +import { nextTick, isReserved, getOuterHTML } from '../util/index' + +export default class Component { + constructor (options) { + this.$options = options + this._data = options.data + const el = this._el = document.querySelector(options.el) + const render = compile(getOuterHTML(el)) + this._el.innerHTML = '' + Object.keys(options.data).forEach(key => this._proxy(key)) + if (options.methods) { + Object.keys(options.methods).forEach(key => { + this[key] = options.methods[key].bind(this) + }) + } + this._ob = observe(options.data) + this._watchers = [] + this._watcher = new Watcher(this, render, this._update) + this._update(this._watcher.value) + } + + _update (vtree) { + if (!this._tree) { + patch(this._el, vtree) + } else { + patch(this._tree, vtree) + } + this._tree = vtree + } + + _renderClass (dynamic, cls) { + dynamic = dynamic + ? typeof dynamic === 'string' + ? dynamic + : Object.keys(dynamic).filter(key => dynamic[key]).join(' ') + : '' + return cls + ? cls + (dynamic ? ' ' + dynamic : '') + : dynamic + } + + __flatten__ (arr) { + var res = [] + for (var i = 0, l = arr.length; i < l; i++) { + var e = arr[i] + if (Array.isArray(e)) { + for (var j = 0, k = e.length; j < k; j++) { + if (e[j]) { + res.push(e[j]) + } + } + } else if (e) { + res.push(e) + } + } + return res + } + + _proxy (key) { + if (!isReserved(key)) { + // need to store ref to self here + // because these getter/setters might + // be called by child scopes via + // prototype inheritance. + var self = this + Object.defineProperty(self, key, { + configurable: true, + enumerable: true, + get: function proxyGetter () { + return self._data[key] + }, + set: function proxySetter (val) { + self._data[key] = val + } + }) + } + } +} + +Component.prototype.__h__ = h +Component.nextTick = nextTick diff --git a/src/observer/array.js b/src/observer/array.js new file mode 100644 index 00000000000..8b247aa5a63 --- /dev/null +++ b/src/observer/array.js @@ -0,0 +1,88 @@ +import { def } from '../util/index' + +const arrayProto = Array.prototype +export const arrayMethods = Object.create(arrayProto) + +/** + * Intercept mutating methods and emit events + */ + +;[ + 'push', + 'pop', + 'shift', + 'unshift', + 'splice', + 'sort', + 'reverse' +] +.forEach(function (method) { + // cache original method + var original = arrayProto[method] + def(arrayMethods, method, function mutator () { + // avoid leaking arguments: + // http://jsperf.com/closure-with-arguments + var i = arguments.length + var args = new Array(i) + while (i--) { + args[i] = arguments[i] + } + var result = original.apply(this, args) + var ob = this.__ob__ + var inserted + switch (method) { + case 'push': + inserted = args + break + case 'unshift': + inserted = args + break + case 'splice': + inserted = args.slice(2) + break + } + if (inserted) ob.observeArray(inserted) + // notify change + ob.dep.notify() + return result + }) +}) + +/** + * Swap the element at the given index with a new value + * and emits corresponding event. + * + * @param {Number} index + * @param {*} val + * @return {*} - replaced element + */ + +def( + arrayProto, + '$set', + function $set (index, val) { + if (index >= this.length) { + this.length = Number(index) + 1 + } + return this.splice(index, 1, val)[0] + } +) + +/** + * Convenience method to remove the element at given index or target element reference. + * + * @param {*} item + */ + +def( + arrayProto, + '$remove', + function $remove (item) { + /* istanbul ignore if */ + if (!this.length) return + var index = this.indexOf(item) + if (index > -1) { + return this.splice(index, 1) + } + } +) diff --git a/src/observer/batcher.js b/src/observer/batcher.js new file mode 100644 index 00000000000..76c80307053 --- /dev/null +++ b/src/observer/batcher.js @@ -0,0 +1,107 @@ +import config from '../config' +import { + warn, + nextTick, + devtools +} from '../util/index' + +// we have two separate queues: one for directive updates +// and one for user watcher registered via $watch(). +// we want to guarantee directive updates to be called +// before user watchers so that when user watchers are +// triggered, the DOM would have already been in updated +// state. + +var queueIndex +var queue = [] +var userQueue = [] +var has = {} +var circular = {} +var waiting = false +var internalQueueDepleted = false + +/** + * Reset the batcher's state. + */ + +function resetBatcherState () { + queue = [] + userQueue = [] + has = {} + circular = {} + waiting = internalQueueDepleted = false +} + +/** + * Flush both queues and run the watchers. + */ + +function flushBatcherQueue () { + runBatcherQueue(queue) + internalQueueDepleted = true + runBatcherQueue(userQueue) + resetBatcherState() +} + +/** + * Run the watchers in a single queue. + * + * @param {Array} queue + */ + +function runBatcherQueue (queue) { + // do not cache length because more watchers might be pushed + // as we run existing watchers + for (queueIndex = 0; queueIndex < queue.length; queueIndex++) { + var watcher = queue[queueIndex] + var id = watcher.id + has[id] = null + watcher.run() + // in dev build, check and stop circular updates. + if (process.env.NODE_ENV !== 'production' && has[id] != null) { + circular[id] = (circular[id] || 0) + 1 + if (circular[id] > config._maxUpdateCount) { + warn( + 'You may have an infinite update loop for watcher ' + + 'with expression "' + watcher.expression + '"', + watcher.vm + ) + break + } + } + } +} + +/** + * Push a watcher into the watcher queue. + * Jobs with duplicate IDs will be skipped unless it's + * pushed when the queue is being flushed. + * + * @param {Watcher} watcher + * properties: + * - {Number} id + * - {Function} run + */ + +export function pushWatcher (watcher) { + var id = watcher.id + if (has[id] == null) { + if (internalQueueDepleted && !watcher.user) { + // an internal watcher triggered by a user watcher... + // let's run it immediately after current user watcher is done. + userQueue.splice(queueIndex + 1, 0, watcher) + } else { + // push watcher into appropriate queue + var q = watcher.user + ? userQueue + : queue + has[id] = q.length + q.push(watcher) + // queue the flush + if (!waiting) { + waiting = true + nextTick(flushBatcherQueue) + } + } + } +} diff --git a/src/observer/dep.js b/src/observer/dep.js new file mode 100644 index 00000000000..5ba37aaa1b6 --- /dev/null +++ b/src/observer/dep.js @@ -0,0 +1,58 @@ +let uid = 0 + +/** + * A dep is an observable that can have multiple + * directives subscribing to it. + * + * @constructor + */ + +export default function Dep () { + this.id = uid++ + this.subs = [] +} + +// the current target watcher being evaluated. +// this is globally unique because there could be only one +// watcher being evaluated at any time. +Dep.target = null + +/** + * Add a directive subscriber. + * + * @param {Directive} sub + */ + +Dep.prototype.addSub = function (sub) { + this.subs.push(sub) +} + +/** + * Remove a directive subscriber. + * + * @param {Directive} sub + */ + +Dep.prototype.removeSub = function (sub) { + this.subs.$remove(sub) +} + +/** + * Add self as a dependency to the target watcher. + */ + +Dep.prototype.depend = function () { + Dep.target.addDep(this) +} + +/** + * Notify all subscribers of a new value. + */ + +Dep.prototype.notify = function () { + // stablize the subscriber list first + var subs = this.subs.slice() + for (var i = 0, l = subs.length; i < l; i++) { + subs[i].update() + } +} diff --git a/src/observer/index.js b/src/observer/index.js new file mode 100644 index 00000000000..a1198ccbfa1 --- /dev/null +++ b/src/observer/index.js @@ -0,0 +1,240 @@ +import Dep from './dep' +import { arrayMethods } from './array' +import { + def, + isArray, + isPlainObject, + hasProto, + hasOwn +} from '../util/index' + +const arrayKeys = Object.getOwnPropertyNames(arrayMethods) + +/** + * By default, when a reactive property is set, the new value is + * also converted to become reactive. However in certain cases, e.g. + * v-for scope alias and props, we don't want to force conversion + * because the value may be a nested value under a frozen data structure. + * + * So whenever we want to set a reactive property without forcing + * conversion on the new value, we wrap that call inside this function. + */ + +let shouldConvert = true +export function withoutConversion (fn) { + shouldConvert = false + fn() + shouldConvert = true +} + +/** + * Observer class that are attached to each observed + * object. Once attached, the observer converts target + * object's property keys into getter/setters that + * collect dependencies and dispatches updates. + * + * @param {Array|Object} value + * @constructor + */ + +export function Observer (value) { + this.value = value + this.dep = new Dep() + def(value, '__ob__', this) + if (isArray(value)) { + var augment = hasProto + ? protoAugment + : copyAugment + augment(value, arrayMethods, arrayKeys) + this.observeArray(value) + } else { + this.walk(value) + } +} + +// Instance methods + +/** + * Walk through each property and convert them into + * getter/setters. This method should only be called when + * value type is Object. + * + * @param {Object} obj + */ + +Observer.prototype.walk = function (obj) { + var keys = Object.keys(obj) + for (var i = 0, l = keys.length; i < l; i++) { + this.convert(keys[i], obj[keys[i]]) + } +} + +/** + * Observe a list of Array items. + * + * @param {Array} items + */ + +Observer.prototype.observeArray = function (items) { + for (var i = 0, l = items.length; i < l; i++) { + observe(items[i]) + } +} + +/** + * Convert a property into getter/setter so we can emit + * the events when the property is accessed/changed. + * + * @param {String} key + * @param {*} val + */ + +Observer.prototype.convert = function (key, val) { + defineReactive(this.value, key, val) +} + +/** + * Add an owner vm, so that when $set/$delete mutations + * happen we can notify owner vms to proxy the keys and + * digest the watchers. This is only called when the object + * is observed as an instance's root $data. + * + * @param {Vue} vm + */ + +Observer.prototype.addVm = function (vm) { + (this.vms || (this.vms = [])).push(vm) +} + +/** + * Remove an owner vm. This is called when the object is + * swapped out as an instance's $data object. + * + * @param {Vue} vm + */ + +Observer.prototype.removeVm = function (vm) { + this.vms.$remove(vm) +} + +// helpers + +/** + * Augment an target Object or Array by intercepting + * the prototype chain using __proto__ + * + * @param {Object|Array} target + * @param {Object} src + */ + +function protoAugment (target, src) { + /* eslint-disable no-proto */ + target.__proto__ = src + /* eslint-enable no-proto */ +} + +/** + * Augment an target Object or Array by defining + * hidden properties. + * + * @param {Object|Array} target + * @param {Object} proto + */ + +function copyAugment (target, src, keys) { + for (var i = 0, l = keys.length; i < l; i++) { + var key = keys[i] + def(target, key, src[key]) + } +} + +/** + * Attempt to create an observer instance for a value, + * returns the new observer if successfully observed, + * or the existing observer if the value already has one. + * + * @param {*} value + * @param {Vue} [vm] + * @return {Observer|undefined} + * @static + */ + +export function observe (value, vm) { + if (!value || typeof value !== 'object') { + return + } + var ob + if ( + hasOwn(value, '__ob__') && + value.__ob__ instanceof Observer + ) { + ob = value.__ob__ + } else if ( + shouldConvert && + (isArray(value) || isPlainObject(value)) && + Object.isExtensible(value) && + !value._isVue + ) { + ob = new Observer(value) + } + if (ob && vm) { + ob.addVm(vm) + } + return ob +} + +/** + * Define a reactive property on an Object. + * + * @param {Object} obj + * @param {String} key + * @param {*} val + */ + +export function defineReactive (obj, key, val) { + var dep = new Dep() + + var property = Object.getOwnPropertyDescriptor(obj, key) + if (property && property.configurable === false) { + return + } + + // cater for pre-defined getter/setters + var getter = property && property.get + var setter = property && property.set + + var childOb = observe(val) + Object.defineProperty(obj, key, { + enumerable: true, + configurable: true, + get: function reactiveGetter () { + var value = getter ? getter.call(obj) : val + if (Dep.target) { + dep.depend() + if (childOb) { + childOb.dep.depend() + } + if (isArray(value)) { + for (var e, i = 0, l = value.length; i < l; i++) { + e = value[i] + e && e.__ob__ && e.__ob__.dep.depend() + } + } + } + return value + }, + set: function reactiveSetter (newVal) { + var value = getter ? getter.call(obj) : val + if (newVal === value) { + return + } + if (setter) { + setter.call(obj, newVal) + } else { + val = newVal + } + childOb = observe(newVal) + dep.notify() + } + }) +} diff --git a/src/observer/watcher.js b/src/observer/watcher.js new file mode 100644 index 00000000000..8ac4c10a15d --- /dev/null +++ b/src/observer/watcher.js @@ -0,0 +1,319 @@ +import config from '../config' +import Dep from './dep' +import { pushWatcher } from './batcher' +import { + extend, + warn, + isArray, + isObject, + nextTick +} from '../util/index' + +let uid = 0 + +/** + * A watcher parses an expression, collects dependencies, + * and fires callback when the expression value changes. + * This is used for both the $watch() api and directives. + * + * @param {Vue} vm + * @param {String|Function} expOrFn + * @param {Function} cb + * @param {Object} options + * - {Array} filters + * - {Boolean} twoWay + * - {Boolean} deep + * - {Boolean} user + * - {Boolean} sync + * - {Boolean} lazy + * - {Function} [preProcess] + * - {Function} [postProcess] + * @constructor + */ + +export default function Watcher (vm, expOrFn, cb, options) { + // mix in options + if (options) { + extend(this, options) + } + var isFn = typeof expOrFn === 'function' + this.vm = vm + vm._watchers.push(this) + this.expression = expOrFn + this.cb = cb + this.id = ++uid // uid for batching + this.active = true + this.dirty = this.lazy // for lazy watchers + this.deps = [] + this.newDeps = [] + this.depIds = Object.create(null) + this.newDepIds = null + this.prevError = null // for async error stacks + // parse expression for getter/setter + if (isFn) { + this.getter = expOrFn + this.setter = undefined + } else { + warn('vue-lite only supports watching functions.') + } + this.value = this.lazy + ? undefined + : this.get() + // state for avoiding false triggers for deep and Array + // watchers during vm._digest() + this.queued = this.shallow = false +} + +/** + * Evaluate the getter, and re-collect dependencies. + */ + +Watcher.prototype.get = function () { + this.beforeGet() + var scope = this.scope || this.vm + var value + try { + value = this.getter.call(scope, scope) + } catch (e) { + if ( + process.env.NODE_ENV !== 'production' && + config.warnExpressionErrors + ) { + warn( + 'Error when evaluating expression ' + + '"' + this.expression + '": ' + e.toString(), + this.vm + ) + } + } + // "touch" every property so they are all tracked as + // dependencies for deep watching + if (this.deep) { + traverse(value) + } + if (this.preProcess) { + value = this.preProcess(value) + } + if (this.filters) { + value = scope._applyFilters(value, null, this.filters, false) + } + if (this.postProcess) { + value = this.postProcess(value) + } + this.afterGet() + return value +} + +/** + * Set the corresponding value with the setter. + * + * @param {*} value + */ + +Watcher.prototype.set = function (value) { + var scope = this.scope || this.vm + if (this.filters) { + value = scope._applyFilters( + value, this.value, this.filters, true) + } + try { + this.setter.call(scope, scope, value) + } catch (e) { + if ( + process.env.NODE_ENV !== 'production' && + config.warnExpressionErrors + ) { + warn( + 'Error when evaluating setter ' + + '"' + this.expression + '": ' + e.toString(), + this.vm + ) + } + } +} + +/** + * Prepare for dependency collection. + */ + +Watcher.prototype.beforeGet = function () { + Dep.target = this + this.newDepIds = Object.create(null) + this.newDeps.length = 0 +} + +/** + * Add a dependency to this directive. + * + * @param {Dep} dep + */ + +Watcher.prototype.addDep = function (dep) { + var id = dep.id + if (!this.newDepIds[id]) { + this.newDepIds[id] = true + this.newDeps.push(dep) + if (!this.depIds[id]) { + dep.addSub(this) + } + } +} + +/** + * Clean up for dependency collection. + */ + +Watcher.prototype.afterGet = function () { + Dep.target = null + var i = this.deps.length + while (i--) { + var dep = this.deps[i] + if (!this.newDepIds[dep.id]) { + dep.removeSub(this) + } + } + this.depIds = this.newDepIds + var tmp = this.deps + this.deps = this.newDeps + this.newDeps = tmp +} + +/** + * Subscriber interface. + * Will be called when a dependency changes. + * + * @param {Boolean} shallow + */ + +Watcher.prototype.update = function (shallow) { + if (this.lazy) { + this.dirty = true + } else if (this.sync || !config.async) { + this.run() + } else { + // if queued, only overwrite shallow with non-shallow, + // but not the other way around. + this.shallow = this.queued + ? shallow + ? this.shallow + : false + : !!shallow + this.queued = true + // record before-push error stack in debug mode + /* istanbul ignore if */ + if (process.env.NODE_ENV !== 'production' && config.debug) { + this.prevError = new Error('[vue] async stack trace') + } + pushWatcher(this) + } +} + +/** + * Batcher job interface. + * Will be called by the batcher. + */ + +Watcher.prototype.run = function () { + if (this.active) { + var value = this.get() + if ( + value !== this.value || + // Deep watchers and watchers on Object/Arrays should fire even + // when the value is the same, because the value may + // have mutated; but only do so if this is a + // non-shallow update (caused by a vm digest). + ((isObject(value) || this.deep) && !this.shallow) + ) { + // set new value + var oldValue = this.value + this.value = value + // in debug + async mode, when a watcher callbacks + // throws, we also throw the saved before-push error + // so the full cross-tick stack trace is available. + var prevError = this.prevError + /* istanbul ignore if */ + if (process.env.NODE_ENV !== 'production' && + config.debug && prevError) { + this.prevError = null + try { + this.cb.call(this.vm, value, oldValue) + } catch (e) { + nextTick(function () { + throw prevError + }, 0) + throw e + } + } else { + this.cb.call(this.vm, value, oldValue) + } + } + this.queued = this.shallow = false + } +} + +/** + * Evaluate the value of the watcher. + * This only gets called for lazy watchers. + */ + +Watcher.prototype.evaluate = function () { + // avoid overwriting another watcher that is being + // collected. + var current = Dep.target + this.value = this.get() + this.dirty = false + Dep.target = current +} + +/** + * Depend on all deps collected by this watcher. + */ + +Watcher.prototype.depend = function () { + var i = this.deps.length + while (i--) { + this.deps[i].depend() + } +} + +/** + * Remove self from all dependencies' subcriber list. + */ + +Watcher.prototype.teardown = function () { + if (this.active) { + // remove self from vm's watcher list + // this is a somewhat expensive operation so we skip it + // if the vm is being destroyed or is performing a v-for + // re-render (the watcher list is then filtered by v-for). + if (!this.vm._isBeingDestroyed && !this.vm._vForRemoving) { + this.vm._watchers.$remove(this) + } + var i = this.deps.length + while (i--) { + this.deps[i].removeSub(this) + } + this.active = false + this.vm = this.cb = this.value = null + } +} + +/** + * Recrusively traverse an object to evoke all converted + * getters, so that every nested property inside the object + * is collected as a "deep" dependency. + * + * @param {*} val + */ + +function traverse (val) { + var i, keys + if (isArray(val)) { + i = val.length + while (i--) traverse(val[i]) + } else if (isObject(val)) { + keys = Object.keys(val) + i = keys.length + while (i--) traverse(val[keys[i]]) + } +} diff --git a/src/util/component.js b/src/util/component.js new file mode 100644 index 00000000000..f4a0879933e --- /dev/null +++ b/src/util/component.js @@ -0,0 +1,89 @@ +import { warn } from './debug' +import { resolveAsset } from './options' +import { getAttr, getBindAttr } from './dom' + +export const commonTagRE = /^(div|p|span|img|a|b|i|br|ul|ol|li|h1|h2|h3|h4|h5|h6|code|pre|table|th|td|tr|form|label|input|select|option|nav|article|section|header|footer)$/i +export const reservedTagRE = /^(slot|partial|component)$/i + +let isUnknownElement +if (process.env.NODE_ENV !== 'production') { + isUnknownElement = function (el, tag) { + if (tag.indexOf('-') > -1) { + // http://stackoverflow.com/a/28210364/1070244 + return ( + el.constructor === window.HTMLUnknownElement || + el.constructor === window.HTMLElement + ) + } else { + return ( + /HTMLUnknownElement/.test(el.toString()) && + // Chrome returns unknown for several HTML5 elements. + // https://code.google.com/p/chromium/issues/detail?id=540526 + !/^(data|time|rtc|rb)$/.test(tag) + ) + } + } +} + +/** + * Check if an element is a component, if yes return its + * component id. + * + * @param {Element} el + * @param {Object} options + * @return {Object|undefined} + */ + +export function checkComponentAttr (el, options) { + var tag = el.tagName.toLowerCase() + var hasAttrs = el.hasAttributes() + if (!commonTagRE.test(tag) && !reservedTagRE.test(tag)) { + if (resolveAsset(options, 'components', tag)) { + return { id: tag } + } else { + var is = hasAttrs && getIsBinding(el) + if (is) { + return is + } else if (process.env.NODE_ENV !== 'production') { + var expectedTag = + options._componentNameMap && + options._componentNameMap[tag] + if (expectedTag) { + warn( + 'Unknown custom element: <' + tag + '> - ' + + 'did you mean <' + expectedTag + '>? ' + + 'HTML is case-insensitive, remember to use kebab-case in templates.' + ) + } else if (isUnknownElement(el, tag)) { + warn( + 'Unknown custom element: <' + tag + '> - did you ' + + 'register the component correctly? For recursive components, ' + + 'make sure to provide the "name" option.' + ) + } + } + } + } else if (hasAttrs) { + return getIsBinding(el) + } +} + +/** + * Get "is" binding from an element. + * + * @param {Element} el + * @return {Object|undefined} + */ + +function getIsBinding (el) { + // dynamic syntax + var exp = getAttr(el, 'is') + if (exp != null) { + return { id: exp } + } else { + exp = getBindAttr(el, 'is') + if (exp != null) { + return { id: exp, dynamic: true } + } + } +} diff --git a/src/util/debug.js b/src/util/debug.js new file mode 100644 index 00000000000..275658f78d5 --- /dev/null +++ b/src/util/debug.js @@ -0,0 +1,24 @@ +import config from '../config' +import { hyphenate } from './lang' + +let warn +let formatComponentName + +if (process.env.NODE_ENV !== 'production') { + const hasConsole = typeof console !== 'undefined' + + warn = (msg, vm) => { + if (hasConsole && (!config.silent)) { + console.error('[Vue warn]: ' + msg + (vm ? formatComponentName(vm) : '')) + } + } + + formatComponentName = vm => { + var name = vm._isVue ? vm.$options.name : vm.name + return name + ? ' (found in component: <' + hyphenate(name) + '>)' + : '' + } +} + +export { warn } diff --git a/src/util/dom.js b/src/util/dom.js new file mode 100644 index 00000000000..362639fd27c --- /dev/null +++ b/src/util/dom.js @@ -0,0 +1,388 @@ +import config from '../config' +import { isIE9 } from './env' +import { warn } from './debug' +import { camelize } from './lang' + +/** + * Query an element selector if it's not an element already. + * + * @param {String|Element} el + * @return {Element} + */ + +export function query (el) { + if (typeof el === 'string') { + var selector = el + el = document.querySelector(el) + if (!el) { + process.env.NODE_ENV !== 'production' && warn( + 'Cannot find element: ' + selector + ) + } + } + return el +} + +/** + * Check if a node is in the document. + * Note: document.documentElement.contains should work here + * but always returns false for comment nodes in phantomjs, + * making unit tests difficult. This is fixed by doing the + * contains() check on the node's parentNode instead of + * the node itself. + * + * @param {Node} node + * @return {Boolean} + */ + +export function inDoc (node) { + var doc = document.documentElement + var parent = node && node.parentNode + return doc === node || + doc === parent || + !!(parent && parent.nodeType === 1 && (doc.contains(parent))) +} + +/** + * Get and remove an attribute from a node. + * + * @param {Node} node + * @param {String} _attr + */ + +export function getAttr (node, _attr) { + var val = node.getAttribute(_attr) + if (val !== null) { + node.removeAttribute(_attr) + } + return val +} + +/** + * Get an attribute with colon or v-bind: prefix. + * + * @param {Node} node + * @param {String} name + * @return {String|null} + */ + +export function getBindAttr (node, name) { + var val = getAttr(node, ':' + name) + if (val === null) { + val = getAttr(node, 'v-bind:' + name) + } + return val +} + +/** + * Check the presence of a bind attribute. + * + * @param {Node} node + * @param {String} name + * @return {Boolean} + */ + +export function hasBindAttr (node, name) { + return node.hasAttribute(name) || + node.hasAttribute(':' + name) || + node.hasAttribute('v-bind:' + name) +} + +/** + * Insert el before target + * + * @param {Element} el + * @param {Element} target + */ + +export function before (el, target) { + target.parentNode.insertBefore(el, target) +} + +/** + * Insert el after target + * + * @param {Element} el + * @param {Element} target + */ + +export function after (el, target) { + if (target.nextSibling) { + before(el, target.nextSibling) + } else { + target.parentNode.appendChild(el) + } +} + +/** + * Remove el from DOM + * + * @param {Element} el + */ + +export function remove (el) { + el.parentNode.removeChild(el) +} + +/** + * Prepend el to target + * + * @param {Element} el + * @param {Element} target + */ + +export function prepend (el, target) { + if (target.firstChild) { + before(el, target.firstChild) + } else { + target.appendChild(el) + } +} + +/** + * Replace target with el + * + * @param {Element} target + * @param {Element} el + */ + +export function replace (target, el) { + var parent = target.parentNode + if (parent) { + parent.replaceChild(el, target) + } +} + +/** + * Add event listener shorthand. + * + * @param {Element} el + * @param {String} event + * @param {Function} cb + * @param {Boolean} [useCapture] + */ + +export function on (el, event, cb, useCapture) { + el.addEventListener(event, cb, useCapture) +} + +/** + * Remove event listener shorthand. + * + * @param {Element} el + * @param {String} event + * @param {Function} cb + */ + +export function off (el, event, cb) { + el.removeEventListener(event, cb) +} + +/** + * For IE9 compat: when both class and :class are present + * getAttribute('class') returns wrong value... + * + * @param {Element} el + * @return {String} + */ + +function getClass (el) { + var classname = el.className + if (typeof classname === 'object') { + classname = classname.baseVal || '' + } + return classname +} + +/** + * In IE9, setAttribute('class') will result in empty class + * if the element also has the :class attribute; However in + * PhantomJS, setting `className` does not work on SVG elements... + * So we have to do a conditional check here. + * + * @param {Element} el + * @param {String} cls + */ + +export function setClass (el, cls) { + /* istanbul ignore if */ + if (isIE9 && !/svg$/.test(el.namespaceURI)) { + el.className = cls + } else { + el.setAttribute('class', cls) + } +} + +/** + * Add class with compatibility for IE & SVG + * + * @param {Element} el + * @param {String} cls + */ + +export function addClass (el, cls) { + if (el.classList) { + el.classList.add(cls) + } else { + var cur = ' ' + getClass(el) + ' ' + if (cur.indexOf(' ' + cls + ' ') < 0) { + setClass(el, (cur + cls).trim()) + } + } +} + +/** + * Remove class with compatibility for IE & SVG + * + * @param {Element} el + * @param {String} cls + */ + +export function removeClass (el, cls) { + if (el.classList) { + el.classList.remove(cls) + } else { + var cur = ' ' + getClass(el) + ' ' + var tar = ' ' + cls + ' ' + while (cur.indexOf(tar) >= 0) { + cur = cur.replace(tar, ' ') + } + setClass(el, cur.trim()) + } + if (!el.className) { + el.removeAttribute('class') + } +} + +/** + * Extract raw content inside an element into a temporary + * container div + * + * @param {Element} el + * @param {Boolean} asFragment + * @return {Element|DocumentFragment} + */ + +export function extractContent (el, asFragment) { + var child + var rawContent + /* istanbul ignore if */ + if (isTemplate(el) && isFragment(el.content)) { + el = el.content + } + if (el.hasChildNodes()) { + trimNode(el) + rawContent = asFragment + ? document.createDocumentFragment() + : document.createElement('div') + /* eslint-disable no-cond-assign */ + while (child = el.firstChild) { + /* eslint-enable no-cond-assign */ + rawContent.appendChild(child) + } + } + return rawContent +} + +/** + * Trim possible empty head/tail text and comment + * nodes inside a parent. + * + * @param {Node} node + */ + +export function trimNode (node) { + var child + /* eslint-disable no-sequences */ + while (child = node.firstChild, isTrimmable(child)) { + node.removeChild(child) + } + while (child = node.lastChild, isTrimmable(child)) { + node.removeChild(child) + } + /* eslint-enable no-sequences */ +} + +function isTrimmable (node) { + return node && ( + (node.nodeType === 3 && !node.data.trim()) || + node.nodeType === 8 + ) +} + +/** + * Check if an element is a template tag. + * Note if the template appears inside an SVG its tagName + * will be in lowercase. + * + * @param {Element} el + */ + +export function isTemplate (el) { + return el.tagName && + el.tagName.toLowerCase() === 'template' +} + +/** + * Create an "anchor" for performing dom insertion/removals. + * This is used in a number of scenarios: + * - fragment instance + * - v-html + * - v-if + * - v-for + * - component + * + * @param {String} content + * @param {Boolean} persist - IE trashes empty textNodes on + * cloneNode(true), so in certain + * cases the anchor needs to be + * non-empty to be persisted in + * templates. + * @return {Comment|Text} + */ + +export function createAnchor (content, persist) { + var anchor = config.debug + ? document.createComment(content) + : document.createTextNode(persist ? ' ' : '') + anchor.__v_anchor = true + return anchor +} + +/** + * Find a component ref attribute that starts with $. + * + * @param {Element} node + * @return {String|undefined} + */ + +var refRE = /^v-ref:/ +export function findRef (node) { + if (node.hasAttributes()) { + var attrs = node.attributes + for (var i = 0, l = attrs.length; i < l; i++) { + var name = attrs[i].name + if (refRE.test(name)) { + return camelize(name.replace(refRE, '')) + } + } + } +} + +/** + * Get outerHTML of elements, taking care + * of SVG elements in IE as well. + * + * @param {Element} el + * @return {String} + */ + +export function getOuterHTML (el) { + if (el.outerHTML) { + return el.outerHTML + } else { + var container = document.createElement('div') + container.appendChild(el.cloneNode(true)) + return container.innerHTML + } +} diff --git a/src/util/env.js b/src/util/env.js new file mode 100644 index 00000000000..aca899d3601 --- /dev/null +++ b/src/util/env.js @@ -0,0 +1,106 @@ +/* global MutationObserver */ + +// can we use __proto__? +export const hasProto = '__proto__' in {} + +// Browser environment sniffing +export const inBrowser = + typeof window !== 'undefined' && + Object.prototype.toString.call(window) !== '[object Object]' + +// detect devtools +export const devtools = inBrowser && window.__VUE_DEVTOOLS_GLOBAL_HOOK__ + +// UA sniffing for working around browser-specific quirks +const UA = inBrowser && window.navigator.userAgent.toLowerCase() +export const isIE9 = UA && UA.indexOf('msie 9.0') > 0 +export const isAndroid = UA && UA.indexOf('android') > 0 + +let transitionProp +let transitionEndEvent +let animationProp +let animationEndEvent + +// Transition property/event sniffing +if (inBrowser && !isIE9) { + const isWebkitTrans = + window.ontransitionend === undefined && + window.onwebkittransitionend !== undefined + const isWebkitAnim = + window.onanimationend === undefined && + window.onwebkitanimationend !== undefined + transitionProp = isWebkitTrans + ? 'WebkitTransition' + : 'transition' + transitionEndEvent = isWebkitTrans + ? 'webkitTransitionEnd' + : 'transitionend' + animationProp = isWebkitAnim + ? 'WebkitAnimation' + : 'animation' + animationEndEvent = isWebkitAnim + ? 'webkitAnimationEnd' + : 'animationend' +} + +export { + transitionProp, + transitionEndEvent, + animationProp, + animationEndEvent +} + +/** + * Defer a task to execute it asynchronously. Ideally this + * should be executed as a microtask, so we leverage + * MutationObserver if it's available, and fallback to + * setTimeout(0). + * + * @param {Function} cb + * @param {Object} ctx + */ + +export const nextTick = (function () { + var callbacks = [] + var pending = false + var timerFunc + function nextTickHandler () { + pending = false + var copies = callbacks.slice(0) + callbacks = [] + for (var i = 0; i < copies.length; i++) { + copies[i]() + } + } + + /* istanbul ignore if */ + if (typeof MutationObserver !== 'undefined') { + var counter = 1 + var observer = new MutationObserver(nextTickHandler) + var textNode = document.createTextNode(counter) + observer.observe(textNode, { + characterData: true + }) + timerFunc = function () { + counter = (counter + 1) % 2 + textNode.data = counter + } + } else { + // webpack attempts to inject a shim for setImmediate + // if it is used as a global, so we have to work around that to + // avoid bundling unnecessary code. + const context = inBrowser + ? window + : typeof global !== 'undefined' ? global : {} + timerFunc = context.setImmediate || setTimeout + } + return function (cb, ctx) { + var func = ctx + ? function () { cb.call(ctx) } + : cb + callbacks.push(func) + if (pending) return + pending = true + timerFunc(nextTickHandler, 0) + } +})() diff --git a/src/util/index.js b/src/util/index.js new file mode 100644 index 00000000000..7352358f414 --- /dev/null +++ b/src/util/index.js @@ -0,0 +1,7 @@ +export * from './lang' +export * from './env' +export * from './dom' +export * from './options' +export * from './component' +export * from './debug' +export { defineReactive } from '../observer/index' diff --git a/src/util/lang.js b/src/util/lang.js new file mode 100644 index 00000000000..b497e1fdf52 --- /dev/null +++ b/src/util/lang.js @@ -0,0 +1,407 @@ +export function isPrimitive (s) { + return typeof s === 'string' || typeof s === 'number' +} + +/** + * Set a property on an object. Adds the new property and + * triggers change notification if the property doesn't + * already exist. + * + * @param {Object} obj + * @param {String} key + * @param {*} val + * @public + */ + +export function set (obj, key, val) { + if (hasOwn(obj, key)) { + obj[key] = val + return + } + if (obj._isVue) { + set(obj._data, key, val) + return + } + var ob = obj.__ob__ + if (!ob) { + obj[key] = val + return + } + ob.convert(key, val) + ob.dep.notify() + if (ob.vms) { + var i = ob.vms.length + while (i--) { + var vm = ob.vms[i] + vm._proxy(key) + vm._digest() + } + } + return val +} + +/** + * Delete a property and trigger change if necessary. + * + * @param {Object} obj + * @param {String} key + */ + +export function del (obj, key) { + if (!hasOwn(obj, key)) { + return + } + delete obj[key] + var ob = obj.__ob__ + if (!ob) { + return + } + ob.dep.notify() + if (ob.vms) { + var i = ob.vms.length + while (i--) { + var vm = ob.vms[i] + vm._unproxy(key) + vm._digest() + } + } +} + +var hasOwnProperty = Object.prototype.hasOwnProperty +/** + * Check whether the object has the property. + * + * @param {Object} obj + * @param {String} key + * @return {Boolean} + */ +export function hasOwn (obj, key) { + return hasOwnProperty.call(obj, key) +} + +/** + * Check if an expression is a literal value. + * + * @param {String} exp + * @return {Boolean} + */ + +var literalValueRE = /^\s?(true|false|-?[\d\.]+|'[^']*'|"[^"]*")\s?$/ +export function isLiteral (exp) { + return literalValueRE.test(exp) +} + +/** + * Check if a string starts with $ or _ + * + * @param {String} str + * @return {Boolean} + */ + +export function isReserved (str) { + var c = (str + '').charCodeAt(0) + return c === 0x24 || c === 0x5F +} + +/** + * Guard text output, make sure undefined outputs + * empty string + * + * @param {*} value + * @return {String} + */ + +export function _toString (value) { + return value == null + ? '' + : value.toString() +} + +/** + * Check and convert possible numeric strings to numbers + * before setting back to data + * + * @param {*} value + * @return {*|Number} + */ + +export function toNumber (value) { + if (typeof value !== 'string') { + return value + } else { + var parsed = Number(value) + return isNaN(parsed) + ? value + : parsed + } +} + +/** + * Convert string boolean literals into real booleans. + * + * @param {*} value + * @return {*|Boolean} + */ + +export function toBoolean (value) { + return value === 'true' + ? true + : value === 'false' + ? false + : value +} + +/** + * Strip quotes from a string + * + * @param {String} str + * @return {String | false} + */ + +export function stripQuotes (str) { + var a = str.charCodeAt(0) + var b = str.charCodeAt(str.length - 1) + return a === b && (a === 0x22 || a === 0x27) + ? str.slice(1, -1) + : str +} + +/** + * Camelize a hyphen-delmited string. + * + * @param {String} str + * @return {String} + */ + +var camelizeRE = /-(\w)/g +export function camelize (str) { + return str.replace(camelizeRE, toUpper) +} + +function toUpper (_, c) { + return c ? c.toUpperCase() : '' +} + +/** + * Hyphenate a camelCase string. + * + * @param {String} str + * @return {String} + */ + +var hyphenateRE = /([a-z\d])([A-Z])/g +export function hyphenate (str) { + return str + .replace(hyphenateRE, '$1-$2') + .toLowerCase() +} + +/** + * Converts hyphen/underscore/slash delimitered names into + * camelized classNames. + * + * e.g. my-component => MyComponent + * some_else => SomeElse + * some/comp => SomeComp + * + * @param {String} str + * @return {String} + */ + +var classifyRE = /(?:^|[-_\/])(\w)/g +export function classify (str) { + return str.replace(classifyRE, toUpper) +} + +/** + * Simple bind, faster than native + * + * @param {Function} fn + * @param {Object} ctx + * @return {Function} + */ + +export function bind (fn, ctx) { + return function (a) { + var l = arguments.length + return l + ? l > 1 + ? fn.apply(ctx, arguments) + : fn.call(ctx, a) + : fn.call(ctx) + } +} + +/** + * Convert an Array-like object to a real Array. + * + * @param {Array-like} list + * @param {Number} [start] - start index + * @return {Array} + */ + +export function toArray (list, start) { + start = start || 0 + var i = list.length - start + var ret = new Array(i) + while (i--) { + ret[i] = list[i + start] + } + return ret +} + +/** + * Mix properties into target object. + * + * @param {Object} to + * @param {Object} from + */ + +export function extend (to, from) { + var keys = Object.keys(from) + var i = keys.length + while (i--) { + to[keys[i]] = from[keys[i]] + } + return to +} + +/** + * Quick object check - this is primarily used to tell + * Objects from primitive values when we know the value + * is a JSON-compliant type. + * + * @param {*} obj + * @return {Boolean} + */ + +export function isObject (obj) { + return obj !== null && typeof obj === 'object' +} + +/** + * Strict object type check. Only returns true + * for plain JavaScript objects. + * + * @param {*} obj + * @return {Boolean} + */ + +var toString = Object.prototype.toString +var OBJECT_STRING = '[object Object]' +export function isPlainObject (obj) { + return toString.call(obj) === OBJECT_STRING +} + +/** + * Array type check. + * + * @param {*} obj + * @return {Boolean} + */ + +export const isArray = Array.isArray + +/** + * Define a property. + * + * @param {Object} obj + * @param {String} key + * @param {*} val + * @param {Boolean} [enumerable] + */ + +export function def (obj, key, val, enumerable) { + Object.defineProperty(obj, key, { + value: val, + enumerable: !!enumerable, + writable: true, + configurable: true + }) +} + +/** + * Debounce a function so it only gets called after the + * input stops arriving after the given wait period. + * + * @param {Function} func + * @param {Number} wait + * @return {Function} - the debounced function + */ + +export function debounce (func, wait) { + var timeout, args, context, timestamp, result + var later = function () { + var last = Date.now() - timestamp + if (last < wait && last >= 0) { + timeout = setTimeout(later, wait - last) + } else { + timeout = null + result = func.apply(context, args) + if (!timeout) context = args = null + } + } + return function () { + context = this + args = arguments + timestamp = Date.now() + if (!timeout) { + timeout = setTimeout(later, wait) + } + return result + } +} + +/** + * Manual indexOf because it's slightly faster than + * native. + * + * @param {Array} arr + * @param {*} obj + */ + +export function indexOf (arr, obj) { + var i = arr.length + while (i--) { + if (arr[i] === obj) return i + } + return -1 +} + +/** + * Make a cancellable version of an async callback. + * + * @param {Function} fn + * @return {Function} + */ + +export function cancellable (fn) { + var cb = function () { + if (!cb.cancelled) { + return fn.apply(this, arguments) + } + } + cb.cancel = function () { + cb.cancelled = true + } + return cb +} + +/** + * Check if two values are loosely equal - that is, + * if they are plain objects, do they have the same shape? + * + * @param {*} a + * @param {*} b + * @return {Boolean} + */ + +export function looseEqual (a, b) { + /* eslint-disable eqeqeq */ + return a == b || ( + isObject(a) && isObject(b) + ? JSON.stringify(a) === JSON.stringify(b) + : false + ) + /* eslint-enable eqeqeq */ +} diff --git a/src/util/options.js b/src/util/options.js new file mode 100644 index 00000000000..b270a733437 --- /dev/null +++ b/src/util/options.js @@ -0,0 +1,381 @@ +import Vue from '../instance/index' +import config from '../config' +import { + extend, + set, + isObject, + isArray, + isPlainObject, + hasOwn, + camelize, + hyphenate +} from './lang' +import { warn } from './debug' +import { commonTagRE, reservedTagRE } from './component' + +/** + * Option overwriting strategies are functions that handle + * how to merge a parent option value and a child option + * value into the final value. + * + * All strategy functions follow the same signature: + * + * @param {*} parentVal + * @param {*} childVal + * @param {Vue} [vm] + */ + +var strats = config.optionMergeStrategies = Object.create(null) + +/** + * Helper that recursively merges two data objects together. + */ + +function mergeData (to, from) { + var key, toVal, fromVal + for (key in from) { + toVal = to[key] + fromVal = from[key] + if (!hasOwn(to, key)) { + set(to, key, fromVal) + } else if (isObject(toVal) && isObject(fromVal)) { + mergeData(toVal, fromVal) + } + } + return to +} + +/** + * Data + */ + +strats.data = function (parentVal, childVal, vm) { + if (!vm) { + // in a Vue.extend merge, both should be functions + if (!childVal) { + return parentVal + } + if (typeof childVal !== 'function') { + process.env.NODE_ENV !== 'production' && warn( + 'The "data" option should be a function ' + + 'that returns a per-instance value in component ' + + 'definitions.', + vm + ) + return parentVal + } + if (!parentVal) { + return childVal + } + // when parentVal & childVal are both present, + // we need to return a function that returns the + // merged result of both functions... no need to + // check if parentVal is a function here because + // it has to be a function to pass previous merges. + return function mergedDataFn () { + return mergeData( + childVal.call(this), + parentVal.call(this) + ) + } + } else if (parentVal || childVal) { + return function mergedInstanceDataFn () { + // instance merge + var instanceData = typeof childVal === 'function' + ? childVal.call(vm) + : childVal + var defaultData = typeof parentVal === 'function' + ? parentVal.call(vm) + : undefined + if (instanceData) { + return mergeData(instanceData, defaultData) + } else { + return defaultData + } + } + } +} + +/** + * El + */ + +strats.el = function (parentVal, childVal, vm) { + if (!vm && childVal && typeof childVal !== 'function') { + process.env.NODE_ENV !== 'production' && warn( + 'The "el" option should be a function ' + + 'that returns a per-instance value in component ' + + 'definitions.', + vm + ) + return + } + var ret = childVal || parentVal + // invoke the element factory if this is instance merge + return vm && typeof ret === 'function' + ? ret.call(vm) + : ret +} + +/** + * Hooks and param attributes are merged as arrays. + */ + +strats.init = +strats.created = +strats.ready = +strats.attached = +strats.detached = +strats.beforeCompile = +strats.compiled = +strats.beforeDestroy = +strats.destroyed = +strats.activate = function (parentVal, childVal) { + return childVal + ? parentVal + ? parentVal.concat(childVal) + : isArray(childVal) + ? childVal + : [childVal] + : parentVal +} + +/** + * Assets + * + * When a vm is present (instance creation), we need to do + * a three-way merge between constructor options, instance + * options and parent options. + */ + +function mergeAssets (parentVal, childVal) { + var res = Object.create(parentVal) + return childVal + ? extend(res, guardArrayAssets(childVal)) + : res +} + +config._assetTypes.forEach(function (type) { + strats[type + 's'] = mergeAssets +}) + +/** + * Events & Watchers. + * + * Events & watchers hashes should not overwrite one + * another, so we merge them as arrays. + */ + +strats.watch = +strats.events = function (parentVal, childVal) { + if (!childVal) return parentVal + if (!parentVal) return childVal + var ret = {} + extend(ret, parentVal) + for (var key in childVal) { + var parent = ret[key] + var child = childVal[key] + if (parent && !isArray(parent)) { + parent = [parent] + } + ret[key] = parent + ? parent.concat(child) + : [child] + } + return ret +} + +/** + * Other object hashes. + */ + +strats.props = +strats.methods = +strats.computed = function (parentVal, childVal) { + if (!childVal) return parentVal + if (!parentVal) return childVal + var ret = Object.create(null) + extend(ret, parentVal) + extend(ret, childVal) + return ret +} + +/** + * Default strategy. + */ + +var defaultStrat = function (parentVal, childVal) { + return childVal === undefined + ? parentVal + : childVal +} + +/** + * Make sure component options get converted to actual + * constructors. + * + * @param {Object} options + */ + +function guardComponents (options) { + if (options.components) { + var components = options.components = + guardArrayAssets(options.components) + var ids = Object.keys(components) + var def + if (process.env.NODE_ENV !== 'production') { + var map = options._componentNameMap = {} + } + for (var i = 0, l = ids.length; i < l; i++) { + var key = ids[i] + if (commonTagRE.test(key) || reservedTagRE.test(key)) { + process.env.NODE_ENV !== 'production' && warn( + 'Do not use built-in or reserved HTML elements as component ' + + 'id: ' + key + ) + continue + } + // record a all lowercase <-> kebab-case mapping for + // possible custom element case error warning + if (process.env.NODE_ENV !== 'production') { + map[key.replace(/-/g, '').toLowerCase()] = hyphenate(key) + } + def = components[key] + if (isPlainObject(def)) { + components[key] = Vue.extend(def) + } + } + } +} + +/** + * Ensure all props option syntax are normalized into the + * Object-based format. + * + * @param {Object} options + */ + +function guardProps (options) { + var props = options.props + var i, val + if (isArray(props)) { + options.props = {} + i = props.length + while (i--) { + val = props[i] + if (typeof val === 'string') { + options.props[val] = null + } else if (val.name) { + options.props[val.name] = val + } + } + } else if (isPlainObject(props)) { + var keys = Object.keys(props) + i = keys.length + while (i--) { + val = props[keys[i]] + if (typeof val === 'function') { + props[keys[i]] = { type: val } + } + } + } +} + +/** + * Guard an Array-format assets option and converted it + * into the key-value Object format. + * + * @param {Object|Array} assets + * @return {Object} + */ + +function guardArrayAssets (assets) { + if (isArray(assets)) { + var res = {} + var i = assets.length + var asset + while (i--) { + asset = assets[i] + var id = typeof asset === 'function' + ? ((asset.options && asset.options.name) || asset.id) + : (asset.name || asset.id) + if (!id) { + process.env.NODE_ENV !== 'production' && warn( + 'Array-syntax assets must provide a "name" or "id" field.' + ) + } else { + res[id] = asset + } + } + return res + } + return assets +} + +/** + * Merge two option objects into a new one. + * Core utility used in both instantiation and inheritance. + * + * @param {Object} parent + * @param {Object} child + * @param {Vue} [vm] - if vm is present, indicates this is + * an instantiation merge. + */ + +export function mergeOptions (parent, child, vm) { + guardComponents(child) + guardProps(child) + var options = {} + var key + if (child.mixins) { + for (var i = 0, l = child.mixins.length; i < l; i++) { + parent = mergeOptions(parent, child.mixins[i], vm) + } + } + for (key in parent) { + mergeField(key) + } + for (key in child) { + if (!hasOwn(parent, key)) { + mergeField(key) + } + } + function mergeField (key) { + var strat = strats[key] || defaultStrat + options[key] = strat(parent[key], child[key], vm, key) + } + return options +} + +/** + * Resolve an asset. + * This function is used because child instances need access + * to assets defined in its ancestor chain. + * + * @param {Object} options + * @param {String} type + * @param {String} id + * @param {Boolean} warnMissing + * @return {Object|Function} + */ + +export function resolveAsset (options, type, id, warnMissing) { + /* istanbul ignore if */ + if (typeof id !== 'string') { + return + } + var assets = options[type] + var camelizedId + var res = assets[id] || + // camelCase ID + assets[camelizedId = camelize(id)] || + // Pascal Case ID + assets[camelizedId.charAt(0).toUpperCase() + camelizedId.slice(1)] + if (process.env.NODE_ENV !== 'production' && warnMissing && !res) { + warn( + 'Failed to resolve ' + type.slice(0, -1) + ': ' + id, + options + ) + } + return res +} diff --git a/src/vdom/dom.js b/src/vdom/dom.js new file mode 100644 index 00000000000..5491508668d --- /dev/null +++ b/src/vdom/dom.js @@ -0,0 +1,39 @@ +export function createElement(tagName){ + return document.createElement(tagName) +} + +export function createElementNS(namespaceURI, qualifiedName){ + return document.createElementNS(namespaceURI, qualifiedName) +} + +export function createTextNode(text){ + return document.createTextNode(text) +} + +export function insertBefore(parentNode, newNode, referenceNode){ + parentNode.insertBefore(newNode, referenceNode) +} + +export function removeChild(node, child){ + node.removeChild(child) +} + +export function appendChild(node, child){ + node.appendChild(child) +} + +export function parentNode(node){ + return node.parentElement +} + +export function nextSibling(node){ + return node.nextSibling +} + +export function tagName(node){ + return node.tagName +} + +export function setTextContent(node, text){ + node.textContent = text +} diff --git a/src/vdom/h.js b/src/vdom/h.js new file mode 100644 index 00000000000..15af6e46193 --- /dev/null +++ b/src/vdom/h.js @@ -0,0 +1,33 @@ +import VNode from './vnode' +import { isPrimitive, isArray } from '../util/index' + +function addNS(data, children) { + data.ns = 'http://www.w3.org/2000/svg' + if (children !== undefined) { + for (var i = 0; i < children.length; ++i) { + addNS(children[i].data, children[i].children) + } + } +} + +export default function h (tag, b, c) { + var data = {}, children, text, i + if (arguments.length === 3) { + data = b + if (isArray(c)) { children = c } + else if (isPrimitive(c)) { text = c } + } else if (arguments.length === 2) { + if (isArray(b)) { children = b } + else if (isPrimitive(b)) { text = b } + else { data = b } + } + if (isArray(children)) { + for (i = 0; i < children.length; ++i) { + if (isPrimitive(children[i])) children[i] = VNode(undefined, undefined, undefined, children[i]) + } + } + if (tag === 'svg') { + addNS(data, children) + } + return VNode(tag, data, children, text, undefined) +} diff --git a/src/vdom/index.js b/src/vdom/index.js new file mode 100644 index 00000000000..03fca7cebe9 --- /dev/null +++ b/src/vdom/index.js @@ -0,0 +1,17 @@ +import createPatchFunction from './patch' +import h from './h' +import _class from './modules/class' +import style from './modules/style' +import props from './modules/props' +import attrs from './modules/attrs' +import events from './modules/events' + +const patch = createPatchFunction([ + _class, // makes it easy to toggle classes + props, + style, + attrs, + events +]) + +export { patch, h } diff --git a/src/vdom/modules/attrs.js b/src/vdom/modules/attrs.js new file mode 100644 index 00000000000..8da7bf924d3 --- /dev/null +++ b/src/vdom/modules/attrs.js @@ -0,0 +1,42 @@ +var booleanAttrs = ["allowfullscreen", "async", "autofocus", "autoplay", "checked", "compact", "controls", "declare", + "default", "defaultchecked", "defaultmuted", "defaultselected", "defer", "disabled", "draggable", + "enabled", "formnovalidate", "hidden", "indeterminate", "inert", "ismap", "itemscope", "loop", "multiple", + "muted", "nohref", "noresize", "noshade", "novalidate", "nowrap", "open", "pauseonexit", "readonly", + "required", "reversed", "scoped", "seamless", "selected", "sortable", "spellcheck", "translate", + "truespeed", "typemustmatch", "visible"] + +var booleanAttrsDict = {} +for(var i=0, len = booleanAttrs.length; i < len; i++) { + booleanAttrsDict[booleanAttrs[i]] = true +} + +function updateAttrs(oldVnode, vnode) { + var key, cur, old, elm = vnode.elm, + oldAttrs = oldVnode.data.attrs || {}, attrs = vnode.data.attrs || {} + + // update modified attributes, add new attributes + for (key in attrs) { + cur = attrs[key] + old = oldAttrs[key] + if (old !== cur) { + // TODO: add support to namespaced attributes (setAttributeNS) + if(!cur && booleanAttrsDict[key]) + elm.removeAttribute(key) + else + elm.setAttribute(key, cur) + } + } + //remove removed attributes + // use `in` operator since the previous `for` iteration uses it (.i.e. add even attributes with undefined value) + // the other option is to remove all attributes with value == undefined + for (key in oldAttrs) { + if (!(key in attrs)) { + elm.removeAttribute(key) + } + } +} + +export default { + create: updateAttrs, + update: updateAttrs +} diff --git a/src/vdom/modules/class.js b/src/vdom/modules/class.js new file mode 100644 index 00000000000..df27be6ec84 --- /dev/null +++ b/src/vdom/modules/class.js @@ -0,0 +1,12 @@ +import { setClass } from '../../util/index' + +function updateClass (oldVnode, vnode) { + if (vnode.data.class !== undefined) { + setClass(vnode.elm, vnode.data.class || '') + } +} + +export default { + init: updateClass, + update: updateClass +} diff --git a/src/vdom/modules/events.js b/src/vdom/modules/events.js new file mode 100644 index 00000000000..da2c526df8c --- /dev/null +++ b/src/vdom/modules/events.js @@ -0,0 +1,42 @@ +function arrInvoker(arr) { + return function() { + // Special case when length is two, for performance + arr.length === 2 ? arr[0](arr[1]) : arr[0].apply(undefined, arr.slice(1)) + } +} + +function fnInvoker(o) { + return function(ev) { o.fn(ev) } +} + +function updateEventListeners(oldVnode, vnode) { + var name, cur, old, elm = vnode.elm, + oldOn = oldVnode.data.on || {}, on = vnode.data.on + if (!on) return + for (name in on) { + cur = on[name] + old = oldOn[name] + if (old === undefined) { + if (Array.isArray(cur)) { + elm.addEventListener(name, arrInvoker(cur)) + } else { + cur = {fn: cur} + on[name] = cur + elm.addEventListener(name, fnInvoker(cur)) + } + } else if (Array.isArray(old)) { + // Deliberately modify old array since it's captured in closure created with `arrInvoker` + old.length = cur.length + for (var i = 0; i < old.length; ++i) old[i] = cur[i] + on[name] = old + } else { + old.fn = cur + on[name] = old + } + } +} + +export default { + create: updateEventListeners, + update: updateEventListeners +} diff --git a/src/vdom/modules/props.js b/src/vdom/modules/props.js new file mode 100644 index 00000000000..765f5213519 --- /dev/null +++ b/src/vdom/modules/props.js @@ -0,0 +1,21 @@ +function updateProps(oldVnode, vnode) { + var key, cur, old, elm = vnode.elm, + oldProps = oldVnode.data.props || {}, props = vnode.data.props || {} + for (key in oldProps) { + if (!props[key]) { + delete elm[key] + } + } + for (key in props) { + cur = props[key] + old = oldProps[key] + if (old !== cur && (key !== 'value' || elm[key] !== cur)) { + elm[key] = cur + } + } +} + +export default { + create: updateProps, + update: updateProps +} diff --git a/src/vdom/modules/style.js b/src/vdom/modules/style.js new file mode 100644 index 00000000000..674b1c02867 --- /dev/null +++ b/src/vdom/modules/style.js @@ -0,0 +1,73 @@ +// TODO: +// - remove animation related bits +// - include prefix sniffing of v-bind:style + +var raf = (typeof window !== 'undefined' && window.requestAnimationFrame) || setTimeout +var nextFrame = function(fn) { raf(function() { raf(fn) }) } + +function setNextFrame(obj, prop, val) { + nextFrame(function() { obj[prop] = val }) +} + +function updateStyle(oldVnode, vnode) { + var cur, name, elm = vnode.elm, + oldStyle = oldVnode.data.style || {}, + style = vnode.data.style || {}, + oldHasDel = 'delayed' in oldStyle + for (name in oldStyle) { + if (!style[name]) { + elm.style[name] = '' + } + } + for (name in style) { + cur = style[name] + if (name === 'delayed') { + for (name in style.delayed) { + cur = style.delayed[name] + if (!oldHasDel || cur !== oldStyle.delayed[name]) { + setNextFrame(elm.style, name, cur) + } + } + } else if (name !== 'remove' && cur !== oldStyle[name]) { + elm.style[name] = cur + } + } +} + +function applyDestroyStyle(vnode) { + var style, name, elm = vnode.elm, s = vnode.data.style + if (!s || !(style = s.destroy)) return + for (name in style) { + elm.style[name] = style[name] + } +} + +function applyRemoveStyle(vnode, rm) { + var s = vnode.data.style + if (!s || !s.remove) { + rm() + return + } + var name, elm = vnode.elm, idx, i = 0, maxDur = 0, + compStyle, style = s.remove, amount = 0, applied = [] + for (name in style) { + applied.push(name) + elm.style[name] = style[name] + } + compStyle = getComputedStyle(elm) + var props = compStyle['transition-property'].split(', ') + for (; i < props.length; ++i) { + if(applied.indexOf(props[i]) !== -1) amount++ + } + elm.addEventListener('transitionend', function(ev) { + if (ev.target === elm) --amount + if (amount === 0) rm() + }) +} + +export default { + create: updateStyle, + update: updateStyle, + destroy: applyDestroyStyle, + remove: applyRemoveStyle +} diff --git a/src/vdom/patch.js b/src/vdom/patch.js new file mode 100644 index 00000000000..a3dd6889f9d --- /dev/null +++ b/src/vdom/patch.js @@ -0,0 +1,258 @@ +import VNode from './vnode' +import * as dom from './dom' +import { isPrimitive } from '../util/index' + +const emptyNode = VNode('', {}, [], undefined, undefined) +const hooks = ['create', 'update', 'remove', 'destroy', 'pre', 'post'] + +function isUndef (s) { + return s === undefined +} + +function isDef (s) { + return s !== undefined +} + +function sameVnode (vnode1, vnode2) { + return vnode1.key === vnode2.key && vnode1.sel === vnode2.sel +} + +function createKeyToOldIdx (children, beginIdx, endIdx) { + var i, map = {}, key + for (i = beginIdx; i <= endIdx; ++i) { + key = children[i].key + if (isDef(key)) map[key] = i + } + return map +} + +export default function createPatchFunction (modules, api) { + var i, j, cbs = {} + + if (isUndef(api)) api = dom + + for (i = 0; i < hooks.length; ++i) { + cbs[hooks[i]] = [] + for (j = 0; j < modules.length; ++j) { + if (modules[j][hooks[i]] !== undefined) cbs[hooks[i]].push(modules[j][hooks[i]]) + } + } + + function emptyNodeAt (elm) { + return VNode(api.tagName(elm).toLowerCase(), {}, [], undefined, elm) + } + + function createRmCb (childElm, listeners) { + return function() { + if (--listeners === 0) { + var parent = api.parentNode(childElm) + api.removeChild(parent, childElm) + } + } + } + + function createElm (vnode, insertedVnodeQueue) { + var i, thunk, data = vnode.data + if (isDef(data)) { + if (isDef(i = data.hook) && isDef(i = i.init)) i(vnode) + if (isDef(i = data.vnode)) { + thunk = vnode + vnode = i + } + } + var elm, children = vnode.children, tag = vnode.sel + if (isDef(tag)) { + elm = vnode.elm = isDef(data) && isDef(i = data.ns) + ? api.createElementNS(i, tag) + : api.createElement(tag) + if (Array.isArray(children)) { + for (i = 0; i < children.length; ++i) { + api.appendChild(elm, createElm(children[i], insertedVnodeQueue)) + } + } else if (isPrimitive(vnode.text)) { + api.appendChild(elm, api.createTextNode(vnode.text)) + } + for (i = 0; i < cbs.create.length; ++i) cbs.create[i](emptyNode, vnode) + i = vnode.data.hook // Reuse variable + if (isDef(i)) { + if (i.create) i.create(emptyNode, vnode) + if (i.insert) insertedVnodeQueue.push(vnode) + } + } else { + elm = vnode.elm = api.createTextNode(vnode.text) + } + if (isDef(thunk)) thunk.elm = vnode.elm + return vnode.elm + } + + function addVnodes (parentElm, before, vnodes, startIdx, endIdx, insertedVnodeQueue) { + for (; startIdx <= endIdx; ++startIdx) { + api.insertBefore(parentElm, createElm(vnodes[startIdx], insertedVnodeQueue), before) + } + } + + function invokeDestroyHook (vnode) { + var i, j, data = vnode.data + if (isDef(data)) { + if (isDef(i = data.hook) && isDef(i = i.destroy)) i(vnode) + for (i = 0; i < cbs.destroy.length; ++i) cbs.destroy[i](vnode) + if (isDef(i = vnode.children)) { + for (j = 0; j < vnode.children.length; ++j) { + invokeDestroyHook(vnode.children[j]) + } + } + if (isDef(i = data.vnode)) invokeDestroyHook(i) + } + } + + function removeVnodes (parentElm, vnodes, startIdx, endIdx) { + for (; startIdx <= endIdx; ++startIdx) { + var i, listeners, rm, ch = vnodes[startIdx] + if (isDef(ch)) { + if (isDef(ch.sel)) { + invokeDestroyHook(ch) + listeners = cbs.remove.length + 1 + rm = createRmCb(ch.elm, listeners) + for (i = 0; i < cbs.remove.length; ++i) cbs.remove[i](ch, rm) + if (isDef(i = ch.data) && isDef(i = i.hook) && isDef(i = i.remove)) { + i(ch, rm) + } else { + rm() + } + } else { // Text node + api.removeChild(parentElm, ch.elm) + } + } + } + } + + function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue) { + var oldStartIdx = 0, newStartIdx = 0 + var oldEndIdx = oldCh.length - 1 + var oldStartVnode = oldCh[0] + var oldEndVnode = oldCh[oldEndIdx] + var newEndIdx = newCh.length - 1 + var newStartVnode = newCh[0] + var newEndVnode = newCh[newEndIdx] + var oldKeyToIdx, idxInOld, elmToMove, before + + while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) { + if (isUndef(oldStartVnode)) { + oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left + } else if (isUndef(oldEndVnode)) { + oldEndVnode = oldCh[--oldEndIdx] + } else if (sameVnode(oldStartVnode, newStartVnode)) { + patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue) + oldStartVnode = oldCh[++oldStartIdx] + newStartVnode = newCh[++newStartIdx] + } else if (sameVnode(oldEndVnode, newEndVnode)) { + patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue) + oldEndVnode = oldCh[--oldEndIdx] + newEndVnode = newCh[--newEndIdx] + } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right + patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue) + api.insertBefore(parentElm, oldStartVnode.elm, api.nextSibling(oldEndVnode.elm)) + oldStartVnode = oldCh[++oldStartIdx] + newEndVnode = newCh[--newEndIdx] + } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left + patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue) + api.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm) + oldEndVnode = oldCh[--oldEndIdx] + newStartVnode = newCh[++newStartIdx] + } else { + if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx) + idxInOld = oldKeyToIdx[newStartVnode.key] + if (isUndef(idxInOld)) { // New element + api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm) + newStartVnode = newCh[++newStartIdx] + } else { + elmToMove = oldCh[idxInOld] + patchVnode(elmToMove, newStartVnode, insertedVnodeQueue) + oldCh[idxInOld] = undefined + api.insertBefore(parentElm, elmToMove.elm, oldStartVnode.elm) + newStartVnode = newCh[++newStartIdx] + } + } + } + if (oldStartIdx > oldEndIdx) { + before = isUndef(newCh[newEndIdx+1]) ? null : newCh[newEndIdx+1].elm + addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx, insertedVnodeQueue) + } else if (newStartIdx > newEndIdx) { + removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx) + } + } + + function patchVnode (oldVnode, vnode, insertedVnodeQueue) { + var i, hook + if (isDef(i = vnode.data) && isDef(hook = i.hook) && isDef(i = hook.prepatch)) { + i(oldVnode, vnode) + } + if (isDef(i = oldVnode.data) && isDef(i = i.vnode)) oldVnode = i + if (isDef(i = vnode.data) && isDef(i = i.vnode)) { + patchVnode(oldVnode, i, insertedVnodeQueue) + vnode.elm = i.elm + return + } + var elm = vnode.elm = oldVnode.elm, oldCh = oldVnode.children, ch = vnode.children + if (oldVnode === vnode) return + if (!sameVnode(oldVnode, vnode)) { + var parentElm = api.parentNode(oldVnode.elm) + elm = createElm(vnode, insertedVnodeQueue) + api.insertBefore(parentElm, elm, oldVnode.elm) + removeVnodes(parentElm, [oldVnode], 0, 0) + return + } + if (isDef(vnode.data)) { + for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode) + i = vnode.data.hook + if (isDef(i) && isDef(i = i.update)) i(oldVnode, vnode) + } + if (isUndef(vnode.text)) { + if (isDef(oldCh) && isDef(ch)) { + if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue) + } else if (isDef(ch)) { + if (isDef(oldVnode.text)) api.setTextContent(elm, '') + addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue) + } else if (isDef(oldCh)) { + removeVnodes(elm, oldCh, 0, oldCh.length - 1) + } else if (isDef(oldVnode.text)) { + api.setTextContent(elm, '') + } + } else if (oldVnode.text !== vnode.text) { + api.setTextContent(elm, vnode.text) + } + if (isDef(hook) && isDef(i = hook.postpatch)) { + i(oldVnode, vnode) + } + } + + return function patch (oldVnode, vnode) { + var i, elm, parent + var insertedVnodeQueue = [] + for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]() + + if (isUndef(oldVnode.sel)) { + oldVnode = emptyNodeAt(oldVnode) + } + + if (sameVnode(oldVnode, vnode)) { + patchVnode(oldVnode, vnode, insertedVnodeQueue) + } else { + elm = oldVnode.elm + parent = api.parentNode(elm) + + createElm(vnode, insertedVnodeQueue) + + if (parent !== null) { + api.insertBefore(parent, vnode.elm, api.nextSibling(elm)) + removeVnodes(parent, [oldVnode], 0, 0) + } + } + + for (i = 0; i < insertedVnodeQueue.length; ++i) { + insertedVnodeQueue[i].data.hook.insert(insertedVnodeQueue[i]) + } + for (i = 0; i < cbs.post.length; ++i) cbs.post[i]() + return vnode + } +} diff --git a/src/vdom/vnode.js b/src/vdom/vnode.js new file mode 100644 index 00000000000..628bf873052 --- /dev/null +++ b/src/vdom/vnode.js @@ -0,0 +1,4 @@ +export default function VNode (sel, data, children, text, elm) { + const key = data === undefined ? undefined : data.key + return { sel, data, children, text, elm, key } +} diff --git a/webpack.config.js b/webpack.config.js new file mode 100644 index 00000000000..17c3094910a --- /dev/null +++ b/webpack.config.js @@ -0,0 +1,16 @@ +var path = require('path') + +module.exports = { + entry: path.resolve(__dirname, 'src/index.umd.js'), + output: { + path: path.resolve(__dirname, 'dist'), + filename: 'vue.js', + library: 'Vue', + libraryTarget: 'umd' + }, + module: { + loaders: [ + { test: /\.js/, loader: 'babel', exclude: /node_modules/ } + ] + } +}