sunderls / lsdom Goto Github PK
View Code? Open in Web Editor NEWa self-made js 'library', to learn javascript by mimicking angular.js/react.js/vue.js or sth?
License: MIT License
a self-made js 'library', to learn javascript by mimicking angular.js/react.js/vue.js or sth?
License: MIT License
嗯,一直在说这个lsdom是学习用的所以只支持Chrome,不过想了一想,加上webpack让它装逼装的更香一点为何不是一种学习呢。哈,所以这次就把lsdom装得更像一点。
(略)
个人其实没觉得yarn有什么特别必要的地方,不过用上。w
> yarn add --dev mocha chai babel-register babel-preset-es2015
首先,我们拿diff.js来开刀。 现在的diff传入了add 和 remove感觉不利于测试, 干脆把diff和patch分开好了。
import logger from './logger';
/**
* diff array
* @param {Array} from
* @param {Array} to
* @return {Array} diff with [type, arr, start, end],
* type:1 -- add, type: -1 remove
*/
export const diff = (from, to) => {
logger.group('diff', from, to);
let i = 0;
let totalFrom = from.length;
let j = 0;
let totalTo = to.length;
let result = [];
while (i < totalFrom && j < totalTo){
if (from[i] === to[j]){
i++;
j++;
} else {
let k = from.indexOf(to[j]);
if (k > i){
result.push([-1, from, i, k - 1])
i = k + 1;
j++;
} else {
let l = to.indexOf(from[i]);
if (l > j){
result.push([+1, to, j, l - 1]);
i++;
j = l + 1;
} else {
break;
}
}
}
}
if (i < totalFrom){
result.push([-1, from, i, totalFrom - 1]);
}
if (j < totalTo){
result.push([1, to, j, totalTo - 1]);
}
logger.groupEnd();
return result;
}
为了不在test中显示log,console封装为logger:
logger.js
function noop(){}
let logger = console;
if (typeof process !== 'undefined'){
logger = {
group: noop,
groupEnd: noop,
log: noop
}
}
export default logger;
然后就是test spec了
test/diff.js
import { diff } from '../lib/diff';
import { expect } from 'chai';
describe('diff', () => {
it('should return add when array is pushed', () => {
let a = [1];
let b = [1, 2];
expect(diff(a, b)).to.deep.equal([[1, b, 1, 1]]);
});
it('should return add when array is spliced', () => {
let a = [1, 2];
let b = [1];
let c = [1, 2, 3];
let d = [1, 3];
expect(diff(a, b)).to.deep.equal([[-1, a, 1, 1]]);
expect(diff(c, d)).to.deep.equal([[-1, c, 1, 1]]);
});
it('should return mixed when arrays are different', () => {
let a = [1, 2];
let b = [3];
let c = [1, 2, 3, 4, 5];
let d = [3, 6, 5];
expect(diff(a, b)).to.deep.equal([[-1, a, 0, 1], [+1, b, 0, 0]]);
expect(diff(c, d)).to.deep.equal([[-1, c, 0, 1], [-1, c, 3, 4], [1, d, 1, 2]]);
});
})
package.json
"scripts": {
"test": "mocha --require babel-register",
}
>yarn test
可以看到3个测试用例全部通过,yeah。
测试通过了,但是原来的app运行不了了,因为浏览器环境不支持export,ok,上webpack。首先lib更名为src,然后把苦文件打包为dist/lsdom.js
,然后就是体力活了,不赘述了,详情请看最后的commit 链接。
有个问题是 watcher.js中在Object.prototype中定义了get, set,这导致babel编译过后的代码无法在Chrome上执行,一直如下错误:
A property cannot both have accessors and be writable or have a value,
再现方式:
Object.prototype.get = function(){}
Object.defineProperty({}, 'foo', {value: 'bar'})
所以需要删除Object.prototype.set,相应的domParser中对Model的处理更新为:
} else if (name === 'model'){
let parsed = parse(str);
$dom.addEventListener('input', () => {
// suppose only can set `scope.xxx` to model
component.scope[parsed.expression.replace('scope.', '')] = $dom.value;
});
bindNode($dom, 'value', component, parsed, {parentWatcher});
}
另外 webpack -p
可以minify,把这个加到build里面。
免费的CI为何不用,添加.travis.yml
文件,登陆travis就OK了。然后readme就能看到闪亮的build:passing
图标。
Component.list = {
'todo-item': TodoItem,
'add-todo': AddTodo,
'todo-app': TodoApp
}
上面面这段代码太糟糕了,怎么都想去掉了。想来想去没有啥办法,所以只能把component的创建改为了factory,诶?貌似之前从factory改成class了,原因是避免单例模式,oh,当时没有深入思考偷了懒,实际上可以不用class解决这个问题。
component.js
import { parseDom } from './domParser';
import { defineGetterSetter } from './getterSetter';
/**
* component class
*/
class Component {
/**
* create a component factory
* @param {String} name
* @param {options} options - {props, scope, methods, template}
*/
static create(name, options){
let component = {
create(){
let instance = Object.create(options);
if (instance.scope){
instance.scope = instance.scope();
defineGetterSetter(instance.scope);
}
Component.instances.push(instance);
return instance;
}
}
Component.list[name] = component;
return component;
}
/**
* render to a dom node
* @param {String} name - component name
* @param {DOMNode} target - target dom node
*/
static render(compnentName, target){
let component = Component.list[compnentName].create();
target.innerHTML = component.tmpl;
parseDom(target, component);
}
}
Component.instances = [];
Component.list = {};
export default Component;
浅显易懂,把options转换为一个factory。注意由于scope需要不断的复制,感觉深度复制也不是个事儿,干脆规定scope必须是个function,这样create的时候执行就好了。 这个又和vue.js走到一起了。嘛,
runtime.js
import Component from './lib/component';
window.LSDom = {
Component
}
// component todo-item
LSDom.Component.create('todo-item', {
props: ['todo', 'remove'],
tmpl: `<li><div class="view">
<input class="toggle" type="checkbox">
<label>{props.todo.name}</label>
<button class="destroy" click="(e) => props.remove(props.todo)"></button>
</div></li>`
});
LSDom.Component.create('add-todo', {
props: ['addItem'],
tmpl: `<input type="text" class="new-todo" placeholder="What needs to be done?" model="scope.newItemName" keypress='(e) => add(e)'>`,
scope: () => {
return {
newItemName: ''
}
},
add(e){
// add when enter key is pressed
if (e.which === 13){
this.props.addItem({
name: this.scope.newItemName
});
this.scope.newItemName = '';
}
}
});
LSDom.Component.create('todo-app', {
tmpl: `<div class="todoapp">
<h1>todos</h1>
<ul class="todo-list">
<todo-item for="item in scope.todos" todo="item" remove="remove"></todo-item>
</ul>
<p><add-todo todos="scope.todos" addItem="add"></add-todo></p>
</div>`,
scope: () => {
return {
todos: [{name: 'a'}]
}
},
remove(item){
console.log('TodoApp: remove', item.name);
let index = this.scope.todos.indexOf(item);
this.scope.todos.splice(index, 1);
},
add(item){
this.scope.todos.push(item);
}
});
// init
LSDom.Component.render('todo-app', document.getElementById('app'));
感觉顺畅多了,嗯,虽然LSDom有点不好读。 嘛,以后再说。
同时为了让app好看,我借用了todomvc的css。
<!DOCTYPE html>
<html>
<head>
<title></title>
<meta charset="utf8">
<link rel="stylesheet" href="./index.css">
<script src="./lsdom.min.js"></script>
</head>
<body>
<div id="app"></div>
<script src="./index.js"></script>
</body>
</html>
为了实现 todomvc的例子,添加item的时候,需要支持回车键。可以在上面的demo js中看到模版中已经强行加上了keypress='(e) => add(e)'
,这个感觉还可以再优化一下,下次再聊。这次先总之支持上。需要在parseDom的时候把event传入。
domParser.js
} else if (['click', 'keypress'].includes(name)){
let parsed = parse(str);
// suppose event handler expression are all closure functions
$dom.addEventListener(name, (e) => {
parsed.update.call(component)(e);
}, false);
怎么能没有一个主页? 在github的设置用点击下按钮就完事儿了。需要做的变化是把demo/
改为docs/
。
嗯,这次没有什么太值得说的,主要做了些边边角角的易用性提升。下次想要针对事件进行优化,敬请关键。
本次的commit在这里
item的添加,删除,数量的显示都没有问题了,现在需要实现:
首先 add-todo
的add中,默认done:false
:
add(e){
// add when enter key is pressed
if (e.which === 13){
if (this.scope.newItemName){
this.props.addItem({
name: this.scope.newItemName,
done: false
});
this.scope.newItemName = '';
}
}
}
然后todo-app
中也需要设置tab的flag
scope: () => {
return {
todos: [{name: 'a', done: true}, {name: 'b', done: false}],
tab: 'all'
}
},
todo-item
中需要根据done的状态添加class
tmpl: `<li classname="props.todo.done ? 'completed' : ''"><div class="view">
<input class="toggle" type="checkbox">
<label>{props.todo.name}</label>
<button class="destroy" click="(e) => props.remove(props.todo)"></button>
</div></li>`
这里如果直接用class的话,会给浏览器造成困扰,换成classname好了(不过如果我们是在tmpl给添加到dom之前的话就可以直接用class了)
bindNode.js
case 'className':
newWatcher = {
node,
component,
closestArrayWatcher,
expression: parsed.expression,
val: parsed.update.bind(component),
update(oldV, newV){
this.node.className = newV;
},
isModel: true
};
break;
domParser.js
} else if (name === 'classname'){
let parsed = parse(str);
bindNode($dom, 'className', component, parsed, {
parentWatcher
});
} else if (name === 'for'){
这样done与否的状态就绑定完毕了。
todo-item
中加上click事件的处理即可:
// component todo-item
LSDom.Component.create('todo-item', {
props: ['todo', 'remove'],
tmpl: `<li classname="props.todo.done ? 'completed' : ''"><div class="view">
<input class="toggle" type="checkbox" click="(e) => toggle(props.todo)">
<label>{props.todo.name}</label>
<button class="destroy" click="(e) => props.remove(props.todo)"></button>
</div></li>`,
toggle(todo){
todo.done = !todo.done;
}
});
但是<input>
要处理默认的on/off问题,所以checked也需要更名。oh。只好改成ls-checked
了。
<input class="toggle" type="checkbox" click="(e) => toggle(props.todo)" ls-checked="{props.todo.done}">
parseDom中默认的attr处理中,ls-checked
转换名字为checked
然后传给bindNode。
else {
let parsed = parseInterpolation(str);
if (typeof parsed !== 'object'){
$dom.setAttribute(name, parsed);
} else {
let match = name.match(/(\w+-)?(\w+)/);
bindNode($dom, 'attr', component, parsed, {parentWatcher, name: match[2]});
}
}
然后 {props.todo.done}
会被处理成字符串"true", "false"
,在expressoinParser加入对单个interpolation 的检测。
expressionParser
...
update(){
// if only 1 interpolation, this prevent returning string
// such as "true" "false"
if (segs.length === 1 && hasInterpolation) {
return segs[0].update.call(this);
} else {
return segs.reduce((pre, curr) => {
if (typeof curr !== 'string'){
return pre + curr.update.call(this);
}
return pre + curr;
}, '');
}
}
...
最后,在bindNode中对attr的处理中,如果遇到false的值,就把attribute删除。
bindNode.js
case 'attr':
newWatcher = {
node,
component,
closestArrayWatcher,
expression: parsed.expression,
val: parsed.update.bind(component),
update(oldV, newV){
if (newV){
this.node.setAttribute(extra.name, newV);
} else {
this.node.removeAttribute(extra.name);
}
}
};
break;
之前for下面只有1一个name变化点,现在变成了2个,所以scope.todos
的childs变成了4个,这将导致for的增删处理出错。直观的想法是,单个item的watcher需要包裹到一个watcher中。可否实现呢?比如
现在childs有两个watcher:
childs:
watcher1 :
name watcher
class watcher
watcher2:
name watcher
class watcher
增删的时候,因为dom会被删除掉,所以watcher1对应的增删貌似可行,这个watcher对应intermediate component好了。
bindNode.js
case 'for':
....
add(){
...
intermediate.isIntermediate = true;
let intermediateWatcher = {
childs: [],
component: intermediate,
parent: newWatcher,
closestArrayWatcher: newWatcher
};
addChildWatcher(newWatcher, intermediateWatcher);
parseDom(newNode, intermediate, intermediateWatcher);
...
}
remove(){
...
}
...
watcher.js
/**
* add child watcher
*/
export const addChildWatcher = function(parent, child){
if (!parent.childs) {
parent.childs = [];
}
parent.childs.push(child);
}
最后,在bindNode()处理closestArrayWatcher的时候要加入对parent的检测。
bindNode.js
let closestArrayWatcher = extra.parentWatcher && extra.parentWatcher.isArray ?
extra.parentWatcher : extra.parentWatcher.closestArrayWatcher ? extra.parentWatcher.closestArrayWatcher :
extra.closestArrayWatcher;
这下问题解决了。
<todo-app>
中,如何根据scope.tab
的不同来更新todos
数组呢? 更新tab的时候调用:
scope.tab = 'all';
todos存的是所有的todo,view中显示的是经过tab filter之后的todo。所以,这里需要定义一个新的todos -- todosFiltered, 这个需要动态由tab
来计算,所以这需要是一个function,然后在模版中需要表现为一个数组。 这里定义一种新的数据格式 - computed property,这个在Ember 1.x时代就已经出现了,现在我们实现一下。
首先,模版中的todos改为todoFiltered.
<todo-item for="item in todos" todo="item" remove="remove"></todo-item>
其次, scope中增加todosFiltered的定义,为了避免方法太重复,我们不能把todoFilters定义在scope,而是定义在component上。
computed: {
todosFiltered(){
return this.scope.todos.filter(item => this.scope.tab === 'all'
|| (this.scope.tab === 'active' && item.done === false)
|| (this.scope.tab === 'completed' && item.done === true)
);
},
},
在component的处理中,需要把todosFiltered转换为getter/setter。
component.js
static create(name, options){
// transform computed property
if (options.computed){
Object.keys(options.computed).forEach(key => {
let func = options.computed[key];
delete options.computed[key];
Object.defineProperty(options, key, {
get: func,
enumerable : true,
configurable : true
});
});
}
let component = {
create(){
let instance = Object.create(options);
if (instance.scope){
instance.scope = instance.scope();
defineGetterSetter(instance.scope);
}
Component.instances.push(instance);
return instance;
}
}
Component.list[name] = component;
return component;
}
在console中对tab进行修改,可以看到todos能够顺利更新:
> LSDom.Component.instances[0].scope.tab = 'all';
> LSDom.Component.instances[0].scope.tab = 'active';
> LSDom.Component.instances[0].scope.tab = 'completed';
通过上面的computed,list已经可以触发更新了,接下来处理底部的tab切换。如果为了简单,可以不涉及到router,简单绑定点击事件,更新链接的class就ok了,不过这里还是稍微做那么好一点点。我们要处理的共有三个链接:
#
-> 全部#active
-> 还没完成的todo#completed
-> 完成的todo分析一下可以发现,router需要有一下几点:(pushState的类型暂不做考虑)
把url的hash变化看作数据变化的一种的话,router也是某种component,适合作为root component。然后只要这个component能传递path信息给下面的子component的话,就没什么问题了的感觉,而url和component的mapping在router的定义中完成。
router component定义后初始化的时候需要调用render,所以需要给component提供一个能够在加载后执行的方法,这个涉及到生命周期的概念以后再好好总结,总之这里先添加一个mounted
的hook,和一个对自身dom 容器的引用。另外为了让route传递route信息给component,render
中增加一个extra参数。
component.js
/**
* render to a dom node
* @param {String} name - component name
* @param {DOMNode} target - target dom node
*/
static render(compnentName, target, extra){
let component = Component.list[compnentName].create();
target.innerHTML = component.tmpl;
component.$container = target;
Object.assign(component, extra);
// seems problematic
parseDom(target, component, Watchers.root);
if (component.mounted){
component.mounted();
}
}
然后我们创建router Component
LSDom.Component.create('router', {
scope: () => {
return {
map: {
'/:tab?': 'todo-app'
},
route: {}
}
},
mounted(){
// init router
//
Object.keys(this.scope.map).some(route => {
let match = this.__match(route);
if (match){
Object.assign(this.scope.route, match);
}
return match !== null;
});
// transform route to
LSDom.Component.render('todo-app', this.$container, {
route: this.scope.route
});
},
__match(route){
// transform route to regex
let keys = [];
let path = location.hash.slice(1);
let regstr = route.replace(/:\w+/g, (a, b) => {
keys.push(a.slice(1));
return '(\\w+)';
});
let match = path.match(new RegExp(regstr));
if (match){
let params = {};
keys.forEach((key, i) => {
params[key] = match[i + 1];
});
return params;
} else {
return null;
}
}
});
// init
LSDom.Component.render('router', document.getElementById('app'));
首先在scope中,定义mapping,然后在mounted hook中检查匹配的route,然后初始化对应的component。
这下在<todo-app>
中,可以去掉tab的定义,而直接从route中获得了。
scope: () => {
return {
todos: [{name: 'a', done: true}, {name: 'b', done: false}]
}
},
computed: {
todosFiltered(){
return this.scope.todos.filter(item => ['all', ''].includes(this.route.tab)
|| (this.route.tab === 'active' && item.done === false)
|| (this.route.tab === 'completed' && item.done === true)
);
},
},
测试后发现,当从active
切换到all
的时候todo 顺序颠倒了,debug后发现,for的处理中有问题,在删除的时候调整了index但是在增加的时候忘了处理。
首先在for的add中,添加watcher的时候不能简单的push,而是需要插入到特定的index。
watcher.js
/**
* add child watcher
*/
export const addChildWatcher = function(parent, child, index = null){
if (!parent.childs) {
parent.childs = [];
}
if (index){
parent.childs.push(child);
} else {
parent.childs.splice(index, 0, child);
}
}
bindNode.js 中传入index之后,需要对插入部分之后的items的index进行调整。
...
addChildWatcher(newWatcher, intermediateWatcher, i);
parseDom(newNode, intermediate, intermediateWatcher);
parentNode.insertBefore(newNode, start.nextSibling || endAnchor);
}
start = start.nextSibling;
i++;
}
// adjust watchers after insertions
i = to + 1;
while (i < arr.length){
console.log('set index', newWatcher.childs[i].component.__index, 'to',
newWatcher.childs[i].component.__index + to - from + 1);
newWatcher.childs[i].component.__index += to - from + 1;
i += 1;
}
},
remove: (arr, from, to) => {
另外,unwatch中需要把childs也unwatch掉。
watcher.js
/**
* remove setter watchers
* @param {Watcher} watcher - watcher to bind
*/
export const unwatch = function(watcher){
let list = watcher.parent.childs;
list.splice(list.indexOf(watcher), 1);
if (watcher.locations){
watcher.locations.forEach(loc => {
loc.delete(watcher);
});
}
// avoid using forEach, since deleting happens in unwatch
// babel transform for ... of to iterator.
// use a new array.
watcher.childs.slice(0).forEach(unwatch);
}
至此一个router就算完成了,最后需要给tab增加上合适的selected class。
<li>
<a classname="this.route.tab === 'all' ? 'selected' : ''" href="#/">All</a>
</li>
<li>
<a classname="this.route.tab === 'active' ? 'selected' : ''" href="#/active">Active</a>
</li>
<li>
<a classname="this.route.tab === 'completed' ? 'selected' : ''" href="#/completed">Completed</a>
</li>
A declarative, efficient, and flexible JavaScript library for building user interfaces.
🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.
TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
An Open Source Machine Learning Framework for Everyone
The Web framework for perfectionists with deadlines.
A PHP framework for web artisans
Bring data to life with SVG, Canvas and HTML. 📊📈🎉
JavaScript (JS) is a lightweight interpreted programming language with first-class functions.
Some thing interesting about web. New door for the world.
A server is a program made to process requests and deliver data to clients.
Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.
Some thing interesting about visualization, use data art
Some thing interesting about game, make everyone happy.
We are working to build community through open source technology. NB: members must have two-factor auth.
Open source projects and samples from Microsoft.
Google ❤️ Open Source for everyone.
Alibaba Open Source for everyone
Data-Driven Documents codes.
China tencent open source team.