如何从 0 处理一个 vue 文件并实现简单的响应式?
在现在的前端工程化中,打包工具是不可或缺的,其中webpack
无疑是占据了主导地位,当然也有尤大搞的vite
,但是论生态和使用人数,至少在目前webpack
还是更胜一筹。
打包工具能帮助我们打包前端文件,在webpack
中,不同后缀的文件通过不同loader
来处理。
本文就讨论下怎么实现一个处理.vue
文件的loader
,以及用loader
处理完.vue
文件怎么把内容渲染在浏览器上,并实现简单的响应式。
源码地址 gezhicui/vue-webpack
webpack 部分
首先进行 webpack 打包,把.vue
文件通过 vue-loader
处理。
实现一个简易的vue-loader
,通过一系列正则,最终一个.vue
文件的内容会被包装到一个对象中
比方说我现在的.vue 文件写了下面这些内容:
{{ count + 1 }}
那么经过 vue-loader
处理,就会变成一个对象:
{ template: ``, name: 'App', data() { return { count: 0 } }, methods: { plus(num) { this.count += num; }, } }{{ count + 1 }}
那么,在浏览器执行这个文件的时候,我们就能通过createApp
方法,把这个对象使用 createApp
进行处理,挂载到页面上
createApp 实现部分
在 vue 的main.js
文件中,我们通常会把根组件传递给createApp
作为入参,如:
import App from './App'; import { createApp } from '../modules/vue'; createApp(App).mount('#app');
那我们实现的重点就在于createApp
对vue 组件的处理,以及在createApp
的返回内容(就是 vm)中添加mount
方法,实现处理完的节点的挂载
接下来就一步步实现createApp
,首先,我们先来定义一个 vm,一会儿所有的属性都可以放在 vm 上,同时把vue-loader
解析过的文件对象中的内容给解构出来
function createApp(component) { const vm = {}; const { template, methods, data } = component; }
template 解析
在上面经过vur-loader
处理后,template
以字符串形式被放到对象中,所以我们可以拿到 dom 元素字符串,把他转成 dom 元素
/* template: ``, */ vm.$node = createNode(template); function createNode(template) { const _tempNode = document.createElement('div'); _tempNode.innerHTML = template; return getFirstChildNode(_tempNode); }{{ count + 1 }}
这样,我们就拿到了 html 接下来就是对 js 的操作
data 响应式处理
vue 的核心就在于响应式,vue2 通过Object.defineProperty
实现响应式,我们来实现个简单的响应式处理
首先拿到data
,为了创建多个组件时data
不被互相影响,所以data
是一个函数
vm.$data = data(); for (let key in vm.$data) { Object.defineProperty(vm, key, { get() { return vm.$data[key]; }, set(newValue) { vm.$data[key] = newValue; // update触发节点更新,至于实现我放到后面再说 update(vm, key); }, }); }
这样,我们就监听了data
中每个属性的get
和set
,实现了数据的响应式处理
初始化数据池
在上面的 template 解析中,我们已经拿到了template
转换过后的节点,但是有个问题,节点的内容没有经过任何处理,如{{count + 1}}
会原封不动的展示在浏览器中,我们希望的是最终展示的是 count 这个变量+1 的结果,所以我们需要对双括号语法进行解析
我们先定义一个正则表达式,匹配{{}}
中的内容,以及定义一个节点数据池
// 节点数据池 const exprPool = new Map(); // 正则获取双括号中内容 const regExpr = /\{\{(.+?)\}\}/;
然后,从我们刚刚定义的vm.$node
中拿到所有节点,并查看该节点是否有双括号语法,如果有的话存入节点数据池中
const allNodes = $node.querySelectorAll('*'); allNodes.forEach((node) => { // 这里获取到的textContent是原原始的没经过任何处理的节点内容,如{{count + 1}} const vExpression = node.textContent; /* exprMatched:{ 0: "{{ count + 1 }}" 1: " count + 1 " groups: undefined index: 0 input: "{{ count + 1 }}" } */ const exprMatched = vExpression.match(regExpr); // 如果有双括号语法 if (exprMatched) { const poolInfo = checkExpressionHasData($data, exprMatched[1].trim()); // 把节点存入节点数据池 poolInfo && exprPool.set(node, poolInfo); } }); function checkExpressionHasData(data, expression) { for (let key in data) { if (expression.includes(key) && expression !== key) { // count + 1,返回{key:count,expression:count+1} return { key, expression, }; } else if (expression === key) { // count,返回{key:count,expression:count} return { key, expression: key, }; } else { return null; } } }
初始化事件池
处理完双括号语法,我们还需要处理@click
这样的事件语法,首先,我们创建一个事件池,再定义两个正则分别匹配函数
const eventPool = new Map(); // 匹配函数名 const regStringFn = /(.+?)\((.+?)\)/; // 匹配函数参数 const regString = /\'(.+?)\'/;
同样的,我们也需要遍历所有节点
const allNodes = $node.querySelectorAll('*'); allNodes.forEach((node) => { const vClickVal = node.getAttribute(`@click`); if (vClickVal) { /* 比如@click='plus(1)',解析完成的fnInfo就是 fnInfo:{ args: [1] methodName: "plus" } */ const fnInfo = checkFunctionHasArgs(vClickVal); const handler = fnInfo ? //有参函数传入args methods[fnInfo.methodName].bind(vm, ...fnInfo.args) : //无参函数直接绑定 methods[vClickVal].bind(vm); //存入事件池,节点为key,事件为value eventPool.set(node, { type: vClick, handler, }); //删除dom上的attr,不然浏览器查看源代码就会显示自定义事件 这样不好 node.removeAttribute(`@${vClick}`); } }); function checkFunctionHasArgs(str) { const matched = str.match(regStringFn); if (matched) { const argArr = matched[2].split(','); const args = checkIsString(matched[2]) ? argArr // ['1'] : argArr.map((item) => Number(item)); return { methodName: matched[1], args, }; } } function checkIsString(str) { return str.match(regString); }
这样,我们有拥有了节点数据池和事件池,接下来我们就要拿节点数据池和事件池做操作了
绑定事件处理
有了事件池,我们就要把事件池中的事件绑定到 dom 元素上去,让事件能够触发。这步其实是很容易的,因为我们把 vue
事件加入事件池中时,key 是 dom 元素,value 是事件处理函数,只要把他们两个互相绑定就行
function (vm) { //node:key info:value for (let [node, info] of eventPool) { // type:事件类型 handler:事件处理函数 let { type, handler } = info; //在vue中,是用this.function 去访问方法,所以方法要被绑定到vm上 vm[handler.name] = handler; //给节点绑定事件处理函数 node.addEventListener(type, vm[handler.name], false); } }
render 页面
执行完上面的内容,我们就到了最后一步 render 页面
了,我们只要把节点数据池中的节点内容渲染到浏览器上
function render(vm) { exprPool.forEach((info, node) => { _render(vm, node, info); }); } function _render(vm, node, info) { //info:{key: 'count',expression 'count + 1'} const { expression } = info; //expression是一个字符串,为了执行字符串,所以我们需要new Function const r = new Function( 'vm', 'node', ` with (vm) { node.textContent = ${expression}; } ` ); r(vm, node); }
在这里,我们先解决两个问题
- with 是干啥用的?
- 为什么_render 要抽离出来?