Skip to content

Commit

Permalink
feat(compiler): support using js expressions in bindings (#45)
Browse files Browse the repository at this point in the history
* feat(compiler): support using js expressions in bindings

* fix(compiler): support private method

* feat(compiler): remove multiple statements support

* docs: update template event and base
  • Loading branch information
ChrisCindy authored Apr 20, 2022
1 parent b5f2054 commit 34e7e57
Show file tree
Hide file tree
Showing 7 changed files with 296 additions and 17 deletions.
73 changes: 73 additions & 0 deletions packages/pwc-compiler/__tests__/compileTemplate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,4 +131,77 @@ describe('compileTemplate', () => {
'this.#text',
]);
});

it('compile a template with javascript expressions in text interpolation', () => {
const { descriptor } = parse('<template><div>{{ count++ }}</div></template>');
const { templateString, values} = compileTemplateAST(descriptor.template.ast);

expect(templateString).toBe('<div><!--?pwc_t--></div>');
expect(values).toEqual([
'count++',
]);
});

it('compile a template with javascript expressions in attribute bindings', () => {
const { descriptor } = parse('<template><child-component bind="{{ count++ }}" ></child-component></template>');
const { templateString, values} = compileTemplateAST(descriptor.template.ast);

expect(templateString).toBe('<!--?pwc_p--><child-component></child-component>');
expect(values).toEqual([
[
{
name: 'bind',
value: 'count++',
}
],
]);
});

it('compile a template with javascript expressions in event bindings', () => {
const { descriptor } = parse('<template><div @click="{{ count++ }}"></div></template>');
const { templateString, values} = compileTemplateAST(descriptor.template.ast);

expect(templateString).toBe('<!--?pwc_p--><div></div>');
expect(values).toEqual([
[
{
name: 'onclick',
value: '() => (count++)',
capture: false
}
],
]);
});

it('compile a template with calling methods in event bindings', () => {
const { descriptor } = parse(`<template><div @click="{{ say('hello') }}"></div></template>`);
const { templateString, values} = compileTemplateAST(descriptor.template.ast);

expect(templateString).toBe('<!--?pwc_p--><div></div>');
expect(values).toEqual([
[
{
name: 'onclick',
value: `() => (say('hello'))`,
capture: false
}
],
]);
});

it('compile a template with inline arrow function in event bindings', () => {
const { descriptor } = parse(`<template><div @click="{{ (event) => warn('', event) }}"></div></template>`);
const { templateString, values} = compileTemplateAST(descriptor.template.ast);

expect(templateString).toBe('<!--?pwc_p--><div></div>');
expect(values).toEqual([
[
{
name: 'onclick',
value: `(event) => warn('', event)`,
capture: false
}
],
]);
});
});
25 changes: 22 additions & 3 deletions packages/pwc-compiler/src/compileTemplate.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as parse5 from 'parse5';
import type { SFCDescriptor, ElementNode } from './parse';
import { dfs, isEventNameInTemplate, isBindings, getEventInfo, BINDING_REGEXP, INTERPOLATION_REGEXP } from './utils';
import { dfs, isEventNameInTemplate, isBindings, isMemberExpression, getEventInfo, BINDING_REGEXP, INTERPOLATION_REGEXP } from './utils';

export interface AttributeDescriptor {
name: string;
Expand All @@ -17,6 +17,7 @@ export interface CompileTemplateResult {

const TEXT_COMMENT_DATA = '?pwc_t';
const PLACEHOLDER_COMMENT_DATA = '?pwc_p';
const fnExpRE = /^\s*([\w$_]+|(async\s*)?\([^)]*?\))\s*=>|^\s*(async\s+)?function(?:\s+[\w$]+)?\s*\(/;

function createCommentNode(data) {
return {
Expand Down Expand Up @@ -49,16 +50,34 @@ function extractAttributeBindings(node: ElementNode): Array<AttributeDescriptor>
if (isEventNameInTemplate(attr.name)) {
// events
const { eventName, isCapture } = getEventInfo(attr.name);
let expression = attr.value.replace(BINDING_REGEXP, '$1').trim();
if (expression) {
const isMemberExp = isMemberExpression(expression);
const isInlineStatement = !(isMemberExp || fnExpRE.test(expression));
const hasMultipleStatements = expression.includes(';');
if (hasMultipleStatements) {
// TODO: throw error
}
if (isInlineStatement) {
// Use function to block wrap the inline statement expression
expression = `() => (${expression})`;
}
} else {
expression = '() => {}';
}

tempAttributeDescriptor.push({
name: `on${eventName}`,
value: attr.value.replace(BINDING_REGEXP, '$1'),
value: expression,
capture: isCapture,
});
} else {
// attributes
let expression = attr.value.replace(BINDING_REGEXP, '$1').trim();

tempAttributeDescriptor.push({
name: attr.name,
value: attr.value.replace(BINDING_REGEXP, '$1'),
value: expression,
});
}
return false; // remove attribute bindings
Expand Down
3 changes: 1 addition & 2 deletions packages/pwc-compiler/src/transform/genGetTemplateMethod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,8 @@ function createArrayExpression(elements) {
return t.arrayExpression(elements);
}

// this.xxx
function createIdentifier(value) {
return t.identifier(value);
return value === '' ? t.stringLiteral('') : t.identifier(value);
}

/**
Expand Down
2 changes: 1 addition & 1 deletion packages/pwc-compiler/src/utils/getEventInfo.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
const EVENT_REG = /^@([#\w]*)(\.capture)?/;
const EVENT_REG = /^@([#\w]*)(\.capture)?/; // TODO: When there are more modifiers besides capture, the regexp should be modified

export function getEventInfo(name): any {
const eventExecArray = EVENT_REG.exec(name);
Expand Down
86 changes: 86 additions & 0 deletions packages/pwc-compiler/src/utils/is.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,89 @@ export const INTERPOLATION_REGEXP = /\{\{\s*([\s\S]*?)\s*\}\}/g;
export function isBindings(value: string): boolean {
return BINDING_REGEXP.test(value);
}

enum MemberExpLexState {
inMemberExp,
inBrackets,
inParens,
inString,
}
const whitespaceRE = /\s+[.[]\s*|\s*[.[]\s+/g;
const validFirstIdentCharRE = /[A-Za-z_$\xA0-\uFFFF]/;
const validIdentCharRE = /[.?#\w$\xA0-\uFFFF]/;

/**
* Forked from @vue/compiler-core
* Simple lexer to check if an expression is a member expression. This is
* lax and only checks validity at the root level (i.e. does not validate exps
* inside square brackets), but it's ok since these are only used on template
* expressions and false positives are invalid expressions in the first place.
*/
export function isMemberExpression(path: string): boolean {
// remove whitespaces around . or [ first
path = path.trim().replace(whitespaceRE, str => str.trim());

let state = MemberExpLexState.inMemberExp;
let stateStack: MemberExpLexState[] = [];
let currentOpenBracketCount = 0;
let currentOpenParensCount = 0;
let currentStringType: "'" | '"' | '`' | null = null;

for (let index = 0; index < path.length; index++) {
const char = path.charAt(index);
switch (state) {
case MemberExpLexState.inMemberExp:
if (char === '[') {
stateStack.push(state);
state = MemberExpLexState.inBrackets;
currentOpenBracketCount++;
} else if (char === '(') {
stateStack.push(state);
state = MemberExpLexState.inParens;
currentOpenParensCount++;
} else if (
!(index === 0 ? validFirstIdentCharRE : validIdentCharRE).test(char)
) {
return false;
}
break;
case MemberExpLexState.inBrackets:
if (char === '\'' || char === '"' || char === '`') {
stateStack.push(state);
state = MemberExpLexState.inString;
currentStringType = char;
} else if (char === '[') {
currentOpenBracketCount++;
} else if (char === ']') {
if (!--currentOpenBracketCount) {
state = stateStack.pop()!;
}
}
break;
case MemberExpLexState.inParens:
if (char === '\'' || char === '"' || char === '`') {
stateStack.push(state);
state = MemberExpLexState.inString;
currentStringType = char;
} else if (char === '(') {
currentOpenParensCount++;
} else if (char === ')') {
// if the exp ends as a call then it should not be considered valid
if (index === path.length - 1) {
return false;
}
if (!--currentOpenParensCount) {
state = stateStack.pop()!;
}
}
break;
case MemberExpLexState.inString:
if (char === currentStringType) {
state = stateStack.pop()!;
currentStringType = null;
}
break;
}
}
return !currentOpenBracketCount && !currentOpenParensCount;
}
45 changes: 44 additions & 1 deletion website/docs/template/base.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,50 @@ PWC 使用基于 HTML 的模板语法来定义组件渲染的内容。你可以

### 使用 JavaScript 表达式(模板表达式)

// TODO
PWC 在数据绑定中支持完整的 Javascript 表达式:

```html
{{ number + 1 }}

{{ ok ? 'YES' : 'NO' }}

{{ message.split('').reverse().join('') }}

<div :id="`list-${id}`"></div>
```

这些表达式都会被作为 JavaScript ,以组件为作用域解析执行。

在 PWC 单文件组件模板内,JavaScript 表达式可以被使用在如下场景上:

- 在文本插值中 (双大括号)
- 在属性绑定及事件绑定中

#### 仅支持表达式

每个绑定仅支持单一表达式,所以下面的例子都是无效的:

```html
<!-- 这是一个语句,而非表达式 -->
{{ var a = 1 }}

<!-- 条件控制同样不会工作,请使用三元表达式 -->
{{ if (ok) { return message; } }}
```

#### 调用函数

可以在绑定的表达式中使用一个组件暴露的方法:

```html
<span title="{{toTitleDate(date)}}">
{{ formatDate(date) }}
</span>
```

:::tip TIP
绑定在表达式中的方法在组件每次更新时都会被重新调用,因此不应该产生任何副作用,比如改变数据或触发异步操作。
:::

### 属性绑定

Expand Down
79 changes: 69 additions & 10 deletions website/docs/template/event.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,26 +8,85 @@ sidebar_position: 2

用户可以在模板中使用 `@` 语法糖监听 DOM 事件,并在其触发时执行一些 JavaScript 代码。

// TODO
事件处理器的值可以是:

## 事件处理方法
1. 内联事件处理器:事件被触发时执行的 JavaScript 表达式
2. 方法事件处理器:一个组件的属性名、或对某个方法的访问

事件触发回调逻辑复杂时,用户可以直接传入一个函数方法,供 DOM 事件触发后执行:
### 内联事件处理器

示例
内联事件处理器通常用于简单场景,例如

```html
<template>
<button @click="{{this.onSave}}">Save</button>
<div @click="{{this.#count++}}"></div>
</template>
<script>
export default CustomComponent extends HTMLElement {
#count = 0;
}
</script>
```

### 方法事件处理器

内联事件处理器仅支持单一的 JavaScript 表达式,当事件处理器需要处理复杂逻辑时,可以传入一个方法名或对某个方法的调用。例如:

```html
<template>
<div @click="{{this.handleClick}}"></div>
</template>
<script>
export default CustomComponent extends HTMLElement {
handleClick = (event) => {
// `event` 是 DOM 原生事件
if (event) {
alert(event.target.tagName);
}
}
}
</script>
```

```js
export default class CustomComponent extends HTMLElement {
onSave = () => {
console.log('saved');
方法事件处理器会自动接收原生 DOM 事件并触发执行。在上面的例子中,我们能够通过被触发事件的 `event.target.tagName` 访问到该 DOM 元素。

### 在内联处理器中调用方法

除了直接绑定方法名,你还可以在内联事件处理器中调用方法。这允许我们向方法传入自定义参数:

```html
<template>
<div @click="{{this.say('hello')}}"></div>
</template>
<script>
export default CustomComponent extends HTMLElement {
say = (msg) => {
console.log(msg);
}
}
</script>
```

### 在内联事件处理器中访问事件参数

有时我们需要在内联事件处理器中访问原生 DOM 事件。你可以使用内联箭头函数:

```html
<template>
<button @click="{{(event) => warn('Form cannot be submitted yet.', event)}}">
Submit
</button>
</template>
<script>
export default CustomComponent extends HTMLElement {
warn = (msg, event) => {
if (event) {
event.preventDefault();
}
console.log(msg);
}
}
}
</script>
```

## 事件修饰符
Expand Down

0 comments on commit 34e7e57

Please sign in to comment.