书接上一篇: 150行代码教你实现一个低配版的MVVM库(1)- 原理篇
写在前面
为了便于分模块,和阅读,我使用了Typescript来进行coding,总行数是正好150行,最早写DEMO的时候用了ES2015,代码行数应该在100行出头,如果你不会搭ts+webpack的编译UMD环境,你也可以把本文中的ts语法人肉转成es6或者es2015,我相信这对你(一个有志于学写mvvm库的青年)来说没有什么难度。
作为作者呢,虽然最后我会放出源码的地址,你可以去github上扫一眼代码,但我还是希望你们可以跟我一起,打开个文本编辑器,一个模块一个模块把代码人肉敲出来,这样的感觉是不一样的,就好比是你可能之前就阅读过angular,vue的源码,但你现在还不是在读我的文章么?
第一步 先把骨架搭好, 血肉晚点再填充
还是再上一遍设计图
设计的类不多,一共就5个
//SegmentFault.ts
export let SegmentFault = class SegmentFault {
private viewModelPool = {}; //用来维护viewModel的别名alias与viewModel之间的关系
private viewViewModelMap = {};//用来维护viewModel和被绑定的view之间的关系
public registerViewModel(alias:string, vm:object) {};//在sf正式运作之前我们先要注册一个下viewModel并给他起一个别名
public init() {}; //sf库开始运作的入口函数
public refresh(alias:string){}; // 暴露一个强制刷新整个viewModel的方法,因为毕竟有你监控不到的角落
}
SegmentFault是对用户暴露的唯一的对象,就像Angular他会暴露一个angular对象给用户使用一样。
最终,用户会这样来操作SF以达到双向绑定的目的
不妨再看看使用效果
<script src="dist/sf.js"></script> <!-- 这里引入我们的sf.js库-->
<script>
var sf = new SegmentFault(); //生成一个sf的实例
sf.registerViewModel("vm", new ViewModel()); //注册一个viewModel,起一个叫vm的别名
sf.init(); //调用init方法,开始初始化,sf正式开始一些列工作
//以下是viewModel的定义
function ViewModel() {
this.message = "hello, SegmentFault";
this.buttonClickHandler = function() {
this.message = "clicked: " + this.message;
}
}
</script>
有没有觉得SF的API干净利落,清新爽洁!
根据设计图的Step 1,先给已注册的viewModel加上监视,这里我们需要一个Watcher类
export class Watcher {
private sf;
//构造函数里传入一个sf的对象,便于callback调用时的作用域确定。。。这是后话
constructor(sf) {
this.sf = sf;
}
public observe(viewModel, callback) {} //暗中观察
}
再来看一下Step 2, 另一个主要的类Scanner,Scanner是干什么的呢?作用就一个遍历整个DOM Tree把出现sf-xxxx这个attribute的Elements全部挑出来,然后找sf-xxxx = expression,等号右边这个表达式里如果出现了viewModel的alias,那就说么这个element是跟viewModel搭界了,是绑定在一起了,scanner负责把这对"恋人"关系用一个数据结构维护一下,等全部扫描完了一起返回给SegmentFault去听候发落。
//Scanner.ts
export class Scanner {
private prefix = "sf-"; //库的前缀
private viewModelPool;
constructor(viewModelPool) {
this.viewModelPool = viewModelPool; //Scanner肯定是为SegmentFault服务的,所以初始化的时候SegmentFault会把之前注册过的viewModel信息传给Scanner,便于它去扫描。
}
public scanBindDOM():object {} //找出attribute里带sf-,且等号右边表达式里含有viewModel的alias的Element,并返回一个view与viewModel的map
}
接下去,SegmentFault会获得Scanner.scanBindDOM()所返回的view_viewModel Map,来看看这个Map的具体数据结构
//template
{
"vm_alias":[
{
"viewModel":viewModel,
"element":element,
"expression":expression,
"attributeName":attributeName
}
]
}
//如果实际中的DOM Tree是这样的,
<body>
<p sf-text="userVM.username"></p>
<input type="text" sf-value="userVM.username">
</body>
//那么,Scanner扫描到的结果应该是
{
"userVM":[
{
"viewModel": userViewModel,
"element": <p/>,
"expression": "vm.username",
"attributeName": "sf-text"
},
{
"viewModel": userViewModel,
"element": <input>,
"expression": "vm.username",
"attributeName": "sf-value"
}
]
}
我的实现中特地定一个了一个BoundItem类来描述 {"viewModel":viewModel,"element":element,"expression":expression,"attributeName":attributeName}
//BoundItem.ts
export class BoundItem {
public viewModel: object;
public element: Element;
public expression: string;
public attributeName: string;
constructor(viewModel: object, element: Element, expression: string, attributeName: string) {
this.viewModel = viewModel;
this.element = element;
this.expression = expression;
this.attributeName = attributeName;
}
}
拿到view_viewModel map后,SegmentFault会调用Renderer去挨个渲染每一个BoundItem。
export class Renderer{
public render(boundItem:BoundItem) {};
}
好至此,几个主要的类都一一登场了,接下去我们完善下SegmentFault类,让ta和其它几个类联动起来
import {Scanner} from "./Scanner";
import {Watcher} from "./Watcher";
import {Renderer} from "./Renderer";
export let SegmentFault = class SegmentFault {
private viewModelPool = {};
private viewViewModelMap = {};
private renderer = new Renderer();
public init() {
let scanner = new Scanner(this.viewModelPool);
let watcher = new Watcher(this);
//step 1, 暗中观察各个viewModel
for (let key in this.viewModelPool) {
watcher.observe(this.viewModelPool[key],this.viewModelChangedHandler);
}
/step 2 3, 扫描DOM Tree并返回Map
this.viewViewModelMap = scanner.scanBindDOM();
//step 4, 渲染DOM
Object.keys(this.viewViewModelMap).forEach(alias=>{
this.refresh(alias);
});
};
public registerViewModel(alias:string, viewModel:object) {
viewModel["_alias"] = alias;
window[alias] = this.viewModelPool[alias] = viewModel;
};
public refresh(alias:string){
let boundItems = this.viewViewModelMap[alias];
boundItems.forEach(boundItem => {
this.renderer.render(boundItem);
});
}
private viewModelChangedHandler(viewModel,prop) {
this.refresh(viewModel._alias);
}
}
好,写到这里,骨架全部构建完成,你有没有兴趣自己花点时间去填充血肉呢?
我希望你能做到
这里贴出其它几个类的具体实现,仅供参考,你一定可以写得比我更好。
也放出github地址,上面有完整工程
https://github.com/momoko8443...
以及在线演示地址
https://momoko8443.github.io/...
//Watcher.ts
export class Watcher {
private sf;
constructor(sf) {
this.sf = sf;
}
public observe(viewModel, callback) {
let host = this.sf;
for (var key in viewModel) {
var defaultValue = viewModel[key];
(function (k, dv) {
if (k !== "_alias") {
Object.defineProperty(viewModel, k, {
get: function () {
return dv;
},
set: function (value) {
dv = value;
console.log("do something after set a new value");
callback.call(host, viewModel, k);
}
});
}
})(key, defaultValue);
}
}
}
//Scanner.ts
import { BoundItem } from "./BoundItem";
export class Scanner {
private prefix = "sf-";
private viewModelPool;
constructor(viewModelPool) {
this.viewModelPool = viewModelPool;
}
public scanBindDOM() :object{
let boundMap = {};
let boundElements = this.getAllBoundElements(this.prefix);
boundElements.forEach(element => {
for (let i = 0; i < element.attributes.length; i++) {
let attr = element.attributes[i];
if (attr.nodeName.search(this.prefix) > -1) {
let attributeName = attr.nodeName;
let expression = element.getAttribute(attributeName);
for (let alias in this.viewModelPool) {
if (expression.search(alias + ".") != -1) {
let boundItem = new BoundItem(this.viewModelPool[alias], element, expression,attributeName);
if (!boundMap[alias]) {
boundMap[alias] = [boundItem];
} else {
boundMap[alias].push(boundItem);
}
}
}
}
}
});
return boundMap;
}
private fuzzyFind(element:HTMLElement,text:string):HTMLElement {
if (element && element.attributes) {
for (let i = 0; i < element.attributes.length; i++) {
let attr = element.attributes[i];
if (attr.nodeName.search(text) > -1) {
return element;
}
}
}
return null;
}
private getAllBoundElements(prefix): Array<HTMLElement> {
let elements = [];
let allChildren = document.querySelectorAll("*");
for (let i = 0; i < allChildren.length; i++) {
let child: HTMLElement = allChildren[i] as HTMLElement;
let matchElement = this.fuzzyFind(child, prefix);
if (matchElement) {
elements.push(matchElement);
}
}
return elements;
}
}
//BoundItem.ts
export class BoundItem {
public viewModel: object;
public element: Element;
public expression: string;
public attributeName: string;
private interactiveDomConfig = {
"INPUT":{
"text":"input",
"password":"input",
"email":"input",
"url":"input",
"tel":"input",
"radio":"change",
"checkbox":"change",
"color":"change",
"date":"change",
"datetime":"change",
"datetime-local":"change",
"month":"change",
"number":"change",
"range":"change",
"search":"change",
"time":"change",
"week":"change",
"button":"N/A",
"submit":"N/A"
},
"SELECT":"change",
"TEXTAREA":"change"
}
constructor(viewModel: object, element: Element, expression: string, attributeName: string) {
this.viewModel = viewModel;
this.element = element;
this.expression = expression;
this.attributeName = attributeName;
this.addListener(this.element,this.expression);
}
private addListener(element,expression){
let tagName = element.tagName;
let eventName = this.interactiveDomConfig[tagName];
if(!eventName){
return;
}
if(typeof eventName === "object"){
let type = element.getAttribute("type");
eventName = eventName[type];
}
element.addEventListener(eventName, (e)=> {
let newValue = (element as HTMLInputElement).value;
let cmd = expression + "= \"" + newValue + "\"";
try{
eval(cmd);
}catch(e){
console.error(e);
}
});
}
}
//Renderer.ts
import {BoundItem} from "./BoundItem";
export class Renderer{
public render(boundItem:BoundItem) {
var value = this.getValue(boundItem.viewModel, boundItem.expression);
var attribute = boundItem.attributeName.split('-')[1];
if (attribute.toLowerCase() === "innertext") {
attribute = "innerText";
}
boundItem.element[attribute] = value;
};
private getValue(viewModel, expression) {
return (function () {
var alias = viewModel._alias;
var tempScope = {};
tempScope[alias] = viewModel;
try {
var pattern = new RegExp("\\b" + alias + "\\b", "gm");
expression = expression.replace(pattern, "tempScope." + alias);
var result = eval(expression);
tempScope = null;
return result;
} catch (e) {
throw e;
}
})();
}
}
相关阅读
【教学向】150行代码教你实现一个低配版的MVVM库(1)- 原理篇
【教学向】150行代码教你实现一个低配版的MVVM库(2)- 代码篇
【教学向】再加150行代码教你实现一个低配版的web component库(1) —设计篇
【教学向】再加150行代码教你实现一个低配版的web component库(2) —原理篇