简单易懂的MVVM实现之Compiler(二)

上一篇我们已经可以捕获数据的更改,下面为了让将数据的更改与页面绑定,需要通过Compiler解析模板。

模板规则

首先 UI 模板需要定义自己的模板语法,定义数据如何在页面上显示。我们定义一下规则:

表达式模板

使用{{}} 输出表达式。例如<div>{{ name }}</div>会输出作用域上的name变量。

当然也支持变量的属性和方法调用

<div>{{ name.toUpperCase() }}</div>

表达式除了文本,也可以在属性上使用。

<div class="{{name?'has-name':''}}">{{ name.toUpperCase() }}</div>

条件表达式也是支持的

{{ user ? user.name : ''}}{{ user && user.name }}

可以将括号中的表达式理解成赋值表达式的右侧部分,所以下面这种不支持。

{{ var name = user ? user.name : ''; return name}}

DOM 属性

有时候我们需要操作 dom 节点的一些内置属性,例如按钮button组件有内置的disabled属性。

如果我们在属性上通过模板语法

1
<button disabled="{{submiting?true:false}}">提交</button>

会发现这个按钮始终处于禁用状态。因为 html 上属性值是字符串且默认是为true的,所以只要在 html 上定义了disabled,内置的disabled属性始终都不会为false。所以想要真正的控制内置属性,只能通过button.disabled = false来实现。

简单的通过表达式模板来控制有些属性是不行的,所以我们需要定义额外的规则来实现。

属性语法

我们在属性前加入一个:号来表示这个属性是绑定在节点的 dom 属性中,而不是绑定 html 模板的属性中。具体区别在于,在submiting属性触发更改后,会通过button.disabled = false而不是button.setAttribute('disabled', false)来更改属性。

1
<button :disabled="submiting">提交</button>

值得一提的是,属性语法不需要{{}}包裹。

表达式经常会有<div>姓名:{{ name }}</div>这种与普通文本混合的情况,所以需要使用{{}}标记隔离,而属性语法通常不会有这种情况。

事件回调

事件相对而言比较简单,仅需要将 html 的onxxx中的on换成@符号即可。

1
2
3
<button onclick="submit">提交</button>

<button @click="submit">提交</button>

onclick绑定的是全局的submit方法,而@click绑定的只会绑定到当前作用域的submit方法上。

当然,仅仅传入一个回调函数还不能满足我们的需求,因为回调函数仅仅能收到一个事件参数。在很多业务场景下,回调函数应该要支持传入变量和数据。

1
2
<button @click="submit(false, name)">实名提交</button>
<button @click="submit(true, name)">匿名提交</button>
1
2
submit(event:Event, isAnonymous:boolean, name:string){
}

我们约定事件的event对象始终作为第一个参数,其余参数会按顺序写在后面。

实现

Compiler解析模板需要关联具体的作用域对象,

1
2
3
4
5
6
7
8
9
interface IOwner {
$el: HTMLElement;
$watcher: Watcher;
$complier: Compiler;
[key: string]: any;
setData(newData: any): void;
getValue(path: string): any;
getEvent(name: string): (e: Event, ...args: any[]) => void;
}
1
2
3
4
5
class Compiler {
constructor(owner: IOwner) {
this.owner = owner;
}
}

owner是一个双向绑定的作用域,它的内部会维护WatcherCompiler,这个下一篇再说。

init方法调用后,开始从 owner 的根节点开始解析整个模板。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
init() {
this.traversEL(this.owner.$el);
}

private traversEL(el: HTMLElement) {
if (this.traversAttr(el) === false) {
return;
}

for (let i = 0; i < el.childNodes.length; i++) {
const node: any = el.childNodes[i];

// text
if (node.nodeType === 3) {
let nodeValue = node.nodeValue!;

// 文本节点直接赋值
let setNodeValue = (val: string) => {
// chrome 不会触发重绘
// if (node.nodeValue !== val) {
node.nodeValue = val;
// }
};

this.parseTemplateAndSet(nodeValue, setNodeValue);
} else if (node.nodeType === 1) {
this.traversEL(node);
}
}
}

解析属性

在扫描每一个节点前,我们先遍历节点的属性attribute

首先解析属性的原因是,属性包含了directive(指令),指令可能会有自己的子作用域并告知Compiler中止解析。

1
2
3
4
5
6
7
8
9
10
11
12
private traversAttr(node: HTMLElement) {
const attributes = toArray(node.attributes);

// 这一段指令相关代码省略,将来补充
// ......

attributes.forEach(attr => {
// 遍历属性并判断类型
});

return true;
}

模板字符串解析

首先要提一下解析模板字符串的方法。parseTemplateAndSet传入一个模板字符串和一个回调函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
private parseTemplateAndSet(
template: string,
setNodeValue: (val: string) => void
) {
const valueRegexp = /{% raw %}{{([^}]+)}}{% endraw %}/g;

let result = valueRegexp.exec(template);
let allScopeKeys: string[] = [];
let calContexts: Array<{
startIndex: number;
endIndex: number;
cal: () => string;
}> = [];

while (result) {
const { index } = result;
let tpl = result[1];
let fullTpl = result[0];

const parsed = parseExpression(tpl, 'this.owner');
let scopeKeys = parsed.dependencies;

const fn = new Function('return ' + parsed.expression).bind(this);

allScopeKeys = [...allScopeKeys, ...scopeKeys];
calContexts = [
...calContexts,
{
startIndex: index,
endIndex: index + fullTpl.length,
cal: () => fn.apply(this),
},
];

result = valueRegexp.exec(template);
}

const calValue = () => {
let lastend = 0;
let value = '';
for (let i = 0, l = calContexts.length; i < l; i++) {
value += template.slice(lastend, calContexts[i].startIndex);
value += calContexts[i].cal();
value += template.slice(
calContexts[i].endIndex,
i < l - 1 ? calContexts[i + 1].startIndex : undefined
);
lastend = calContexts[i].endIndex;
}

value += template.slice(
lastend,
calContexts[calContexts.length - 1].startIndex
);

return value;
};

allScopeKeys.forEach(k => {
const listener = () => {
setNodeValue(calValue());
};
this.owner.$watcher.addListener(k, listener);
listener();
});
}

方法会把模板字符串编译成一个计算函数,同时获取函数依赖的变量,并通过$watcher监听所有依赖变量的更改。每当依赖的变量有改动时则执行计算函数,并将计算的结果作为参数触发回调。

比如

1
<div>姓名:{{name}},年龄:{{age}}岁</div>

上面的模板中,姓名:{{name}},年龄:{{age + '岁'}}这个模板字符串会解析成四个部分。

  1. 姓名:
  2. name
  3. ,年龄:
  4. age + ‘岁’

其中2依赖了变量name,4依赖了变量age。整个模板字符串依赖了nameage2个变量,所以编译后会同时监听这2个变量的修改,并触发编译好的4个部分的计算函数,将4个部分计算的结果拼接后得到完整的结果,最后触发回调。

DOM 属性

DOM 属性很简单,仅仅需要给对应的属性值赋值即可,需要注意的是需要将truefalse等字符串转换成真实的值。

1
2
3
4
5
6
7
8
9
if (attr.name.startsWith(':')) {
node.removeAttribute(attr.name);
const attrName = attr.name.substr(1);

this.parseTemplateAndSet('{{' + attr.value + '}}', (val: string) => {
// @ts-ignore
node[attrName] = toRealValue(val);
});
}

回调函数

回调函数的处理相对而言要复杂一点。在有参数的情况下,需要将每一个参数作为模板字符串单独进行解析,并在回调执行时,执行所有参数的监听函数并获取模板字符串的值作为参数,最终将得到的参数和回调的原始Event对象一起传入作用域的回调函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
if (attr.name.startsWith('@')) {
node.removeAttribute(attr.name);
const eventName = attr.name.substr(1);
let eventFuncName = attr.value;
const parseds: Array<ReturnType<typeof parseExpression>> = [];
const matched = eventFuncName.match(/([^()]+)\((.+)\)/);
// 带参数的回调
if (matched) {
eventFuncName = matched[1];
const params = matched[2];
params.split(',').forEach(p => {
const parsed = parseExpression(p, 'this.owner');
parseds.push(parsed);
});
}

const cb = this.owner.getEvent(eventFuncName.trim());
if (cb) {
const funcs = parseds.map(parsed => {
return new Function('return ' + parsed.expression).bind(this);
});
node.addEventListener(eventName, e => {
cb.apply(null, [e, ...funcs.map(func => func())]);
});
}
}

普通属性

普通的attribute直接赋值即可。

1
2
3
4
5
let cb = (val: string) => {
node.setAttribute(attr.name, val);
};

this.parseTemplateAndSet(attr.value, cb);

模板一览

让我们看看使用上面规则写出来的模板是什么样子吧。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<h2>信息</h2>
<div>当前时间:{{ currentTimeStr }}</div>
<div>全名:{{ name.toUpperCase() + ' snow' }}</div>
<div>年龄:{{ age }}</div>
<div>性别:<span>{{ genderText }}</span></div>
<div>爱好:{{ extra.like }}</div>
<p>
<button
@click="submit"
:disabled="submiting"
class="{{ submiting ? 'submiting' : '' }}"
>
{{ submiting ? '提交中...' : '提交' }}
</button>
</p>

下一步

通过WatcherCompiler我们已经将模板和数据绑定起来了,甚至已经可以通过 2 者写一个简单的 demo 进行测试了!后续我们将实现上文开始提到的MVVM对象,将他们的绑定关系进行进一步封装,实现更加便利的双绑关系。

源码