知識
不管是網站,軟件還是小程序,都要直接或間接能為您產生價值,我們在追求其視覺表現(xiàn)的同時,更側重于功能的便捷,營銷的便利,運營的高效,讓網站成為營銷工具,讓軟件能切實提升企業(yè)內部管理水平和效率。優(yōu)秀的程序為后期升級提供便捷的支持!
用 Vue 3.0 來寫個小程序框架
發(fā)表時間:2021-1-5
發(fā)布人:葵宇科技
瀏覽次數(shù):53
由于小程序的開發(fā)起來比較原始復雜且繁瑣,跟我們主流的開發(fā)方式差距很大,所以為了提高我們開發(fā)小程序的效率,市面上出現(xiàn)過很多的小程序的框架:mpvue,Taro,uni-app 等等,這些框架或多或少地將我們帶到現(xiàn)代化的開發(fā)方式中來,他們可以讓你使用 React 或者 Vue 來開發(fā)小程序。今天就分享一個如何利用 Vue 3.0 來構建一個小程序的框架。
簡單看看 Vue 3.0 有哪些新特性:
Composition-APIComposition-API 是一套讓你可以很方便抽取邏輯函數(shù)的 API,相比于之前的 Options API,其代碼組織能力更強,相同的邏輯可以寫在同一個地方,各個邏輯之間界限分明。
看下面的例子即可說明:
Fragment, Teleport
有點類似于 React 的 Fragment,使得我們在寫 Vue 的模板時不再限制于需要一個根節(jié)點,在 Vue3.0 里可以有多個根節(jié)點。
Teleport 用一種直接聲明的方式來將子組件安裝到 DOM 中的其他位置,類似于 React 的 Portal,但是功能更加強大。
更好的 TypeScript 支持
現(xiàn)在 Vue 3.0 的代碼都是由 TS 來編寫,加上 Composition-Api,再寫業(yè)務代碼的時候可以無縫切換到 TS 。
Custom Render API
利用這套 API 可以很方便的構建出自定義的渲染層,這個也是我們接下來需要重點講的。
import {
createRenderer,
CreateAppFunction,
} from '@vue/runtime-core';
export const { render, createApp: baseCreateApp } = createRenderer({
patchProp, // 修改 props 的函數(shù)
...nodeOps, // 修改 dom 節(jié)點的函數(shù)
});
render();
復制代碼
小程序
要開發(fā)一個小程序的頁面基本上我們只需要四個文件:
index.js
index.js 就是我們寫代碼邏輯的地方。
- 有一個 Page 函數(shù),里面是對象配置,類似于 Vue 的 options 配置一樣,有一個 data 屬性,存放著初始化的數(shù)據(jù)。
- 如果想要修改數(shù)據(jù)改變視圖,又需要像 react 一樣,需要調用 setData 去修改視圖。
Page({
data: {
text: 'hello word'
},
onLoad() {
this.setData({
text: 'xxxxx'
})
},
onReady() {},
onShow() {},
onHide() {},
onUnload() {},
handleClick() {
this.setData({
text: 'hello word'
})
}
})
復制代碼
index.ttml
index.ttml 是我們寫視圖模板的地方。
- 類似于 vue 的 template,我們需要先定義模板才能顯示視圖
- 注意: 不能直接在 index.js 里面去修改定義的模板的 DOM,只能先定義好,這是由于小程序架構雙線程導致的,分為邏輯層和渲染層,我們寫的 index.js 代碼跑在邏輯層里面,index.ttml 跑在渲染層里面,兩個線程就通過 setData 進行數(shù)據(jù)交換。
index.json
配置小程序頁面和組件的地方,暫時不列出參數(shù),但是一定要有這個文件。
index.ttss
顧名思義,就是寫樣式的地方,類似于 CSS。
模板
小程序為了封裝的方便,可以先提前定義一個模板,然后再需要的地方引入模板即可,有點像 ejs 和 pug 的 import template 的用法
動態(tài)模板
上面說到,小程序里面不能動態(tài)的修改 DOM 節(jié)點,只能提前定義好 template,然后通過 setData 的形式去修改視圖。
但是小程序又有個比較動態(tài)的特性,叫做動態(tài)選擇模板。
// 使用這個模板
<template is="{{type}}" data="http://www.wxapp-union.com/{{item: item}}"/>
復制代碼
上面 is 屬性的 type 就是動態(tài)的,它是個變量,可以根據(jù) type 的值來選擇不同的模板,比如 type 為 view 時,就會渲染我們提前定義好的 view template。
自定義渲染層(非常重要)
重頭戲來了,我們該如何利用 Vue 3.0 方便的自定義渲染層 結合 小程序的動態(tài)選擇模板的特性來去寫一個小程序的框架呢?
import {
createRenderer,
CreateAppFunction,
} from '@vue/runtime-core';
export const { render, createApp: baseCreateApp } = createRenderer({
patchProp, // 修改 props 的函數(shù)
...nodeOps, // 修改 dom 節(jié)點的函數(shù)
});
復制代碼
我們可以看到 `createRenderer`
函數(shù)需要兩個參數(shù),一個是 patchProp,一個是 nodeOps。
nodeOps
nodeOps 代表著修改 node 節(jié)點的一些操作,從而可以去改變視圖,比如在 Vue 3.0 的瀏覽器環(huán)境中,是這么寫的:
import { RendererOptions } from '@vue/runtime-core'
export const svgNS = 'http://www.w3.org/2000/svg'
const doc = (typeof document !== 'undefined' ? document : null) as Document
let tempContainer: HTMLElement
let tempSVGContainer: SVGElement
// 瀏覽器環(huán)境下的 nodeOps
export const nodeOps: Omit<RendererOptions<Node, Element>, 'patchProp'> = {
insert: (child, parent, anchor) => {
parent.insertBefore(child, anchor || null)
},
remove: child => {
const parent = child.parentNode
if (parent) {
parent.removeChild(child)
}
},
createElement: (tag, isSVG, is): Element =>
isSVG
? doc.createElementNS(svgNS, tag)
: doc.createElement(tag, is ? { is } : undefined),
createText: text => doc.createTextNode(text),
createComment: text => doc.createComment(text),
setText: (node, text) => {
node.nodeValue = http://www.wxapp-union.com/text
},
setElementText: (el, text) => {
el.textContent = text
},
parentNode: node => node.parentNode as Element | null,
nextSibling: node => node.nextSibling,
querySelector: selector => doc.querySelector(selector),
setScopeId(el, id) {
el.setAttribute(id,'')
},
cloneNode(el) {
return el.cloneNode(true)
},
}
復制代碼
實際上 Vue 不管數(shù)據(jù)怎么變化,要將數(shù)據(jù)顯示到視圖上都是調用了 DOM 的一些 API,像上面的 doc.createElement 和 doc.createTextNode 等等。
VNode
是由于小程序的限制,我們不能直接像瀏覽器環(huán)境一樣去修改 DOM,那我們可以先模仿瀏覽器的環(huán)境,創(chuàng)造出一個虛擬的 DOM,我們叫做 VNode。
class VNode {
id: number;
type: string;
props?: Record<string, any>;
text?: string;
children: VNode[] = [];
eventListeners?: Record<string, Function | Function[]> | null;
parentNode?: VNode | null;
nextSibling?: VNode | null;
constructor({
id,
type,
props = {},
text,
}: {
id: number;
type: string;
props?: Record<string, any>;
text?: string;
}) {
this.type = type;
this.props = props;
this.text = text;
this.id = id;
}
appendChild(newNode: VNode) {
if (this.children.find((child) => child.id === newNode.id)) {
this.removeChild(newNode);
}
newNode.parentNode = this;
this.children.push(newNode);
setState({ node: newNode, data: newNode.toJSON() }); // 調用了小程序的 setData
}
insertBefore(newNode: VNode, anchor: VNode) {
newNode.parentNode = this;
newNode.nextSibling = anchor;
if (this.children.find((child) => child.id === newNode.id)) {
this.removeChild(newNode);
}
const anchorIndex = this.children.indexOf(anchor);
this.children.splice(anchorIndex, 0, newNode);
setState({
node: this,
key: '.children',
data: this.children.map((c) => c.toJSON()),
}); // 調用了小程序的 setData
}
removeChild(child: VNode) {
const index = this.children.findIndex((node) => node.id === child.id);
if (index < 0) {
return;
}
if (index === 0) {
this.children = [];
} else {
this.children[index - 1].nextSibling = this.children[index + 1];
this.children.splice(index, 1);
}
setState({
node: this,
key: '.children',
data: this.children.map((c) => c.toJSON()),
});
}
setText(text: string) {
if (this.type === TYPE.RAWTEXT) {
this.text = text;
setState({ node: this, key: '.text', data: text });
return;
}
if (!this.children.length) {
this.appendChild(
new VNode({
type: TYPE.RAWTEXT,
id: generate(),
text,
})
);
return;
}
this.children[0].text = text;
setState({ node: this, key: '.children[0].text', data: text });
}
path(): string {
if (!this.parentNode) {
return 'root';
}
const path = this.parentNode.path();
return [
...(path === 'root' ? ['root'] : path),
'.children[',
this.parentNode.children.indexOf(this) + ']',
].join('');
}
toJSON(): RawNode {
if (this.type === TYPE.RAWTEXT) {
return {
type: this.type,
text: this.text,
};
}
return {
id: this.id,
type: this.type,
props: this.props,
children: this.children && this.children.map((c) => c.toJSON()),
text: this.text,
};
}
}
復制代碼
可以看到我們創(chuàng)建的 VNode 類似于 DOM,也有一些操作 Node 節(jié)點的方法,最終生成一個 Node 樹。我們就可以仿照 vue 瀏覽器環(huán)境的 nodeOps 寫法,先去修改我們的 VNode,在修改 Node 節(jié)點的同時里面我們可以去調用小程序的 setData 方法。
// 小程序環(huán)境下的 nodeOps,主要是修改 VNode
export const nodeOps = {
insert: (child: VNode, parent: VNode, anchor?: VNode) => {
if (anchor != null) {
parent.insertBefore(child, anchor);
} else {
parent.appendChild(child);
}
},
remove: (child: VNode) => {
const parent = child.parentNode;
if (parent != null) {
parent.removeChild(child);
}
},
createElement: (tag: string): VNode =>
new VNode({ type: tag, id: generate() }),
createText: (text: string): VNode =>
new VNode({ type: TYPE.RAWTEXT, text, id: generate() }),
createComment: (): VNode => new VNode({ type: TYPE.RAWTEXT, id: generate() }),
setText: (node: VNode, text: string) => {
node.setText(text);
},
setElementText: (el: VNode, text: string) => {
el.setText(text);
},
parentNode: (node: VNode): VNode | null => node.parentNode ?? null,
nextSibling: (node: VNode): VNode | null => node.nextSibling ?? null,
querySelector: (): VNode | null => getApp()._root,
setScopeId(el: VNode, id: string) {
if (el.props) {
const className = el.props.class;
el.props.class = className ? className + ' ' + id : id;
}
},
};
復制代碼
toJSON()
光是創(chuàng)造出 VNode 還不夠,我們得讓它渲染到小程序里面去,小程序要先渲染出數(shù)據(jù)必須是提前在 data 屬性里面定義的數(shù)據(jù),而且只能是普通的數(shù)據(jù)類型。
Page({
data: {
root: {
type: 'view',
props: {
class: 'xxx'
},
children: [...]
}
}
})
復制代碼
toJSON 方法就是可以將一個 VNode 給格式化成普通的對象,讓小程序可以渲染出數(shù)據(jù)。
接口類型如下:
interface RawNode {
id?: number;
type: string; // view,input, button
props?: Record<string, any>;
children?: RawNode[];
text?: string; // 文本
}
復制代碼
是不是跟 VDOM 的結構很熟悉?
path()
我們可以看到在我們定義的 VNode 里面,里面有個 path() 方法,這個方法就是獲取 Node 節(jié)點在整個節(jié)點樹的一個路徑,然后可以利用 path 去修改某一個特定的 Node 節(jié)點。
const path = Node.path(); // root.children[2].props.class
// 然后我們可以直接這樣來更新小程序
this.setData({
'root.children[2].props.class': 'xxxxx'
})
復制代碼
結合動態(tài)選擇模板
<template name="$_TPL">
<block tt:for="{{root.children}}" tt:key="{{id}}">
<template is="{{'$_' + item.type}}" data="http://www.wxapp-union.com/{{item: item}}"/>
</block>
</template>
<template name="$_input">
// input 有三個屬性 class 和 bindinput 和 value 對應 vue 文件 template 里的 input 上的屬性 class @input value
<input class="{{item.props['class']}}" bindinput="{{item.props['bindinput']}}" value="http://www.wxapp-union.com/{{item.props['value']}}">
<block tt:for="{{item.children}}" tt:key="{{id}}">
<template is="{{'$_' + item.type}}" data="http://www.wxapp-union.com/{{item}}"/>
</block>
</input>
</template>
<template name="$_button">
// button 有兩個屬性 class 和 bindTap 對應 vue 文件 template 里的 button 上的屬性
<button class="{{item.props['class']}}" bindtap="{{item.props['bindtap']}}">
<block tt:for="{{item.children}}" tt:key="{{id}}">
<template is="{{'$_' + item.type}}" data="http://www.wxapp-union.com/{{item}}"/>
</block>
</button>
</template>
<template name="$_view">
<view class="{{item.props['class']}}" bindtap="{{item.props['bindtap']}}">
<block tt:for="{{item.children}}" tt:key="{{id}}">
<template is="{{'$_' + item.type}}" data="http://www.wxapp-union.com/{{item}}"/>
</block>
</view>
</template>
<template name="$_rawText">{{item.text}}</template>
復制代碼
編譯層
我們寫的代碼肯定是 Vue 的代碼,不是上面的模板代碼,那么 Vue 的代碼改怎么樣去編譯到上面的模板代碼呢?
先看一下整體架構圖:
Template
如果我們寫的業(yè)務代碼是常見的 vue 指令模板模式,那么我們可以在底層使用 @vue/compile-core 來 parse Vue 的 template,然后遍歷 parse 后的 AST,收集其中用到的 tag 和 props。
import { parse } from '@vue/compiler-sfc';
import {
baseCompile,
} from '@vue/compiler-core';
const { descriptor } = parse(source, {
filename: this.resourcePath,
});
// 遍歷這個 ast 去收集 tag 和 props
const { ast } = baseCompile(descriptor.template.content);
復制代碼
JSX/TSX
如果我們寫的業(yè)務代碼是 JSX/TSX,那么這邊可以寫個收集 Tag 和 props 的 babel plugin,在 babel plugin 里面去遍歷 AST,收集 Tag 和 props。
最終生成的 ttml
假如我們有一個 .vue 文件:
<template>
<div class="container is-fluid">
<div class="subtitle is-3">Add todo list</div>
<div class="field">
<div class="control">
<input class="input is-info" @input="handleInput" :value="http://www.wxapp-union.com/todo" />
</div>
</div>
<button class="button is-primary is-light" @click="handleAdd">Add +</button>
</div>
</template>
<script>
import { ref } from 'vue';
import { useMainStore } from '@/store';
export default {
setup() {
const todo = ref('');
const store = useMainStore();
const handleInput = (e) => {
todo.value = http://www.wxapp-union.com/e.detail.value;
};
const handleAdd = () => {
store.addTodo(todo.value);
};
return {
handleInput,
todo,
handleAdd,
};
},
};
復制代碼
會生成下面的模板:
<template name="$_TPL">
<block tt:for="{{root.children}}" tt:key="{{id}}">
<template is="{{'$_' + item.type}}" data="http://www.wxapp-union.com/{{item: item}}"/>
</block>
</template>
<template name="$_input">
// input 有三個屬性 class 和 bindinput 和 value 對應 vue 文件 template 里的 input 上的屬性 class @input value
<input class="{{item.props['class']}}" bindinput="{{item.props['bindinput']}}" value="http://www.wxapp-union.com/{{item.props['value']}}">
<block tt:for="{{item.children}}" tt:key="{{id}}">
<template is="{{'$_' + item.type}}" data="http://www.wxapp-union.com/{{item}}"/>
</block>
作者:字節(jié)前端
來源:掘金
著作權歸作者所有。商業(yè)轉載請聯(lián)系作者獲得授權,非商業(yè)轉載請注明出處。
復制代碼
動態(tài)模板
上面說到,小程序里面不能動態(tài)的修改 DOM 節(jié)點,只能提前定義好 template,然后通過 setData 的形式去修改視圖。
但是小程序又有個比較動態(tài)的特性,叫做動態(tài)選擇模板。
// 使用這個模板
復制代碼
上面 is 屬性的 type 就是動態(tài)的,它是個變量,可以根據(jù) type 的值來選擇不同的模板,比如 type 為 view 時,就會渲染我們提前定義好的 view template。
自定義渲染層(非常重要)
重頭戲來了,我們該如何利用 Vue 3.0 方便的自定義渲染層 結合 小程序的動態(tài)選擇模板的特性來去寫一個小程序的框架呢?
import {
createRenderer,
CreateAppFunction,
} from '@vue/runtime-core';
export const { render, createApp: baseCreateApp } = createRenderer({
patchProp, // 修改 props 的函數(shù)
...nodeOps, // 修改 dom 節(jié)點的函數(shù)
});
復制代碼
我們可以看到 `createRenderer`
函數(shù)需要兩個參數(shù),一個是 patchProp,一個是 nodeOps。
nodeOps
nodeOps 代表著修改 node 節(jié)點的一些操作,從而可以去改變視圖,比如在 Vue 3.0 的瀏覽器環(huán)境中,是這么寫的:
import { RendererOptions } from '@vue/runtime-core'
export const svgNS = 'http://www.w3.org/2000/svg'
const doc = (typeof document !== 'undefined' ? document : null) as Document
let tempContainer: HTMLElement
let tempSVGContainer: SVGElement
// 瀏覽器環(huán)境下的 nodeOps
export const nodeOps: Omit, 'patchProp'> = {
insert: (child, parent, anchor) => {
parent.insertBefore(child, anchor || null)
},
remove: child => {
const parent = child.parentNode
if (parent) {
parent.removeChild(child)
}
},
createElement: (tag, isSVG, is): Element =>
isSVG
? doc.createElementNS(svgNS, tag)
: doc.createElement(tag, is ? { is } : undefined),
createText: text => doc.createTextNode(text),
createComment: text => doc.createComment(text),
setText: (node, text) => {
node.nodeValue = http://www.wxapp-union.com/text
},
setElementText: (el, text) => {
el.textContent = text
},
parentNode: node => node.parentNode as Element | null,
nextSibling: node => node.nextSibling,
querySelector: selector => doc.querySelector(selector),
setScopeId(el, id) {
el.setAttribute(id,'')
},
cloneNode(el) {
return el.cloneNode(true)
},
}
復制代碼
實際上 Vue 不管數(shù)據(jù)怎么變化,要將數(shù)據(jù)顯示到視圖上都是調用了 DOM 的一些 API,像上面的 doc.createElement 和 doc.createTextNode 等等。
VNode
是由于小程序的限制,我們不能直接像瀏覽器環(huán)境一樣去修改 DOM,那我們可以先模仿瀏覽器的環(huán)境,創(chuàng)造出一個虛擬的 DOM,我們叫做 VNode。
class VNode {
id: number;
type: string;
props?: Record;
text?: string;
children: VNode[] = [];
eventListeners?: Record | null;
parentNode?: VNode | null;
nextSibling?: VNode | null;
constructor({
id,
type,
props = {},
text,
}: {
id: number;
type: string;
props?: Record;
text?: string;
}) {
this.type = type;
this.props = props;
this.text = text;
this.id = id;
}
appendChild(newNode: VNode) {
if (this.children.find((child) => child.id === newNode.id)) {
this.removeChild(newNode);
}
newNode.parentNode = this;
this.children.push(newNode);
setState({ node: newNode, data: newNode.toJSON() }); // 調用了小程序的 setData
}
insertBefore(newNode: VNode, anchor: VNode) {
newNode.parentNode = this;
newNode.nextSibling = anchor;
if (this.children.find((child) => child.id === newNode.id)) {
this.removeChild(newNode);
}
const anchorIndex = this.children.indexOf(anchor);
this.children.splice(anchorIndex, 0, newNode);
setState({
node: this,
key: '.children',
data: this.children.map((c) => c.toJSON()),
}); // 調用了小程序的 setData
}
removeChild(child: VNode) {
const index = this.children.findIndex((node) => node.id === child.id);
if (index < 0) {
return;
}
if (index === 0) {
this.children = [];
} else {
this.children[index - 1].nextSibling = this.children[index + 1];
this.children.splice(index, 1);
}
setState({
node: this,
key: '.children',
data: this.children.map((c) => c.toJSON()),
});
}
setText(text: string) {
if (this.type === TYPE.RAWTEXT) {
this.text = text;
setState({ node: this, key: '.text', data: text });
return;
}
if (!this.children.length) {
this.appendChild(
new VNode({
type: TYPE.RAWTEXT,
id: generate(),
text,
})
);
return;
}
this.children[0].text = text;
setState({ node: this, key: '.children[0].text', data: text });
}
path(): string {
if (!this.parentNode) {
return 'root';
}
const path = this.parentNode.path();
return [
...(path === 'root' ? ['root'] : path),
'.children[',
this.parentNode.children.indexOf(this) + ']',
].join('');
}
toJSON(): RawNode {
if (this.type === TYPE.RAWTEXT) {
return {
type: this.type,
text: this.text,
};
}
return {
id: this.id,
type: this.type,
props: this.props,
children: this.children && this.children.map((c) => c.toJSON()),
text: this.text,
};
}
}
復制代碼
可以看到我們創(chuàng)建的 VNode 類似于 DOM,也有一些操作 Node 節(jié)點的方法,最終生成一個 Node 樹。我們就可以仿照 vue 瀏覽器環(huán)境的 nodeOps 寫法,先去修改我們的 VNode,在修改 Node 節(jié)點的同時里面我們可以去調用小程序的 setData 方法。
// 小程序環(huán)境下的 nodeOps,主要是修改 VNode
export const nodeOps = {
insert: (child: VNode, parent: VNode, anchor?: VNode) => {
if (anchor != null) {
parent.insertBefore(child, anchor);
} else {
parent.appendChild(child);
}
},
remove: (child: VNode) => {
const parent = child.parentNode;
if (parent != null) {
parent.removeChild(child);
}
},
createElement: (tag: string): VNode =>
new VNode({ type: tag, id: generate() }),
createText: (text: string): VNode =>
new VNode({ type: TYPE.RAWTEXT, text, id: generate() }),
createComment: (): VNode => new VNode({ type: TYPE.RAWTEXT, id: generate() }),
setText: (node: VNode, text: string) => {
node.setText(text);
},
setElementText: (el: VNode, text: string) => {
el.setText(text);
},
parentNode: (node: VNode): VNode | null => node.parentNode ?? null,
nextSibling: (node: VNode): VNode | null => node.nextSibling ?? null,
querySelector: (): VNode | null => getApp()._root,
setScopeId(el: VNode, id: string) {
if (el.props) {
const className = el.props.class;
el.props.class = className ? className + ' ' + id : id;
}
},
};
復制代碼
toJSON()
光是創(chuàng)造出 VNode 還不夠,我們得讓它渲染到小程序里面去,小程序要先渲染出數(shù)據(jù)必須是提前在 data 屬性里面定義的數(shù)據(jù),而且只能是普通的數(shù)據(jù)類型。
Page({
data: {
root: {
type: 'view',
props: {
class: 'xxx'
},
children: [...]
}
}
})
復制代碼
toJSON 方法就是可以將一個 VNode 給格式化成普通的對象,讓小程序可以渲染出數(shù)據(jù)。
接口類型如下:
interface RawNode {
id?: number;
type: string; // view,input, button
props?: Record;
children?: RawNode[];
text?: string; // 文本
}
復制代碼
是不是跟 VDOM 的結構很熟悉?
path()
我們可以看到在我們定義的 VNode 里面,里面有個 path() 方法,這個方法就是獲取 Node 節(jié)點在整個節(jié)點樹的一個路徑,然后可以利用 path 去修改某一個特定的 Node 節(jié)點。
const path = Node.path(); // root.children[2].props.class
// 然后我們可以直接這樣來更新小程序
this.setData({
'root.children[2].props.class': 'xxxxx'
})
復制代碼
結合動態(tài)選擇模板
// input 有三個屬性 class 和 bindinput 和 value 對應 vue 文件 template 里的 input 上的屬性 class @input value
// button 有兩個屬性 class 和 bindTap 對應 vue 文件 template 里的 button 上的屬性