前言
本系列文章将记录我尝试着去开发一个JS框架过程,毕竟也已经用了有一段时间的Vue和React,借鉴一下他们的思想(以Vue为主),试试看能否从头开始撸出一个完整的框架,也算是对Vue的一种深度理解吧。PS:本内容不会对一些太细节的地方过于深究。
附上项目地址:点我跳转
首先回到最初的问题,我要构建一个什么?我的目标是构建出一个类Vue的框架,那么响应式的核心就应该通过Object.defineProperty来完成,模板层面我打算采用JSX的形式,通过render函数来解析模板。所以我将它命名为Xue,恰好也是“学”的读音。既然已经明确了目标,那就开始撸起袖子干了。
准备工作
既然是从零开始,第一步当然是创建名为Xue文件夹了,想想是不是还有点小激动呢。第二步,通过npm init创建一个项目,创建package.json。第三步,安装webpack-dev-server,babel等。第四步,配置webpack.config.js和.babelrc。
最后我的目录如下:
- node_modules
- src
-- modules
--- element.js --存放与节点相关的操作
--- hooks.js --存放与生命周期相关的操作
--- init.js --存放与初始化相关的操作
--- state.js --存放与数据绑定相关的操作
-- utils
--- 以下内容比较多,就不一一列举了
-- main.js
- .babelrc
- index.html
- package-lock.json
- package.json
- webpack.config.js
这里提一下如何解析JSX,点击查看babel官网链接。目前我们只用到了解析函数,所以进行如下配置:
{
"presets": [
[
"@babel/preset-react",
{
"pragma": "parseJSX", // default pragma is React.createElement
}
]
]
}
我们使用parseJSX函数进行解析。对于React而言,这里其实就是我们熟悉的React.createElement方法了。然后让我们来写一个parseJSX方法:
function parseJSX(tag, attrs, ...children) {
return {
tag,
attrs,
children
}
}
当遇到JSX时,如const a = <div className='class_a'>hahaha</div>,我们的解析函数parseJSX接收三个参数,并直接将这三个参数合并为一个对象返回,等价于以下代码:
const a = {
tag: 'div',
attrs: {
className: 'class_a'
},
children: ['hahaha']
}
至此,准备工作已经差不多做好了,接下来进入正文。
从Xue构造函数开始
首先为Xue写一个构造函数:
function Xue(options) {
this.init(this, options);
}
// 通过mixin方法扩展原型
// 以下都是一些可能用到的mixin,会逐步填充里面的内容
stateMixin(Xue);
elementMixin(Xue);
initMixin(Xue);
hooksMixin(Xue);
这里为什么不用class,因为这一块的逻辑会比较复杂,所以我们需要分模块编写,用class的写法和扩展原型的写法混在一起感觉比较怪异,虽然class只是语法糖。
然后对options进行一些参数的验证:
function Xue(options) {
// OPTIONS_NORM是一个对象常量,包含了options可能出现的值的类型
// 以OPTIONS_NORM为标准,对options进行类型检查,并且对不符合要求的抛出提示信息并且进行默认赋值操作,防止因为options类型原因导致内部崩溃
checkAndAssignDefault(options, OPTIONS_NORM);
if(typeof options !== 'object') return warn(`options should be an object rather than a/an ${ typeof options }`);
this.init(this, options);
}
接着我们扩展几个比较简单的原型方法:
// 将解析方法挂载至Xue原型上,代替上文中的parseJSX方法
export default const elementMixin = function(Xue) {
Xue.prototype._parseJSX = (tag, attrs, ...children) => {
return {
tag,
attrs,
children
}
}
}
// 扩展生命周期相关的方法
// HOOK_NAMES是一个保存生命周期名称的常量,是一个数组
import { HOOK_NAMES } from '../utils/constant';
export const hooksMixin = function(Xue) {
// 生命周期调用方法,这里需要通过call来修改生命周期内的this指向
Xue.prototype._callHook = function(hookName) {
this.$hooks[hookName].call(this);
};
};
// 初始化生命周期方法,将其挂载至$hooks中
export const initHooks = function() {
HOOK_NAMES.forEach(item => {
this.$hooks[item] = this.$options[item];
})
};
然后开始写我们的init方法:
export const initMixin = function(Xue) {
Xue.prototype.init = (xm, options) => {
// 缓存options和render
xm.$options = options;
xm.$render = xm.$options.render.bind(xm);
this.$hooks = {};
// 初始化生命周期
initHooks.call(xm);
xm._callHook.call(xm, 'beforeCreate');
// 初始化数据挂载
initState.call(xm);
xm._callHook.call(xm, 'created');
// 调用render生成VNode,完成响应式绑定
// ...
xm._callHook.call(xm, 'beforeMount');
// 挂载DOM
// ...
xm._callHook.call(xm, 'mounted');
}
};
至此,我们的init方法大致框架已经构建完成。
数据挂载
接下来先完成initState方法进行数据挂载,这里为了方便,我采用了Set的数据结构来判断是否有重名的data、methods和props,然后拋错。当然这种方式无法具体指出哪个是重名变量,可能遍历一遍会比较好。
// 初始化数据代理
export const initState = function() {
this.$data = this.$options.data() || {};
this.$props = this.$options.props;
this.$methods = this.$options.methods;
const dataNames = Object.keys(this.$data);
const propNames = Object.keys(this.$props);
const methodNames = Object.keys(this.$methods);
// 检测是否有重名的data,methods或者props
const checkedSet = new Set([...dataNames, ...propNames, ...methodNames]);
if(checkedSet.size < dataNames.length + propNames.length + methodNames.length) return warn('you have same name in data, method, props');
// 分别为data,props,methods中的属性代理到this上
dataNames.forEach(name => proxy(this, '$data', name));
propNames.forEach(name => proxy(this, '$props', name));
methodNames.forEach(name => proxy(this, '$methods', name));
// 将data设置为响应式,下面会说
observe(this.$data);
}
proxy方法就是通过Object.defineProperty进行数据劫持,大家都懂得。
export default function proxy(xm, sourceKey, key) {
Object.defineProperty(xm, key, {
get() {
return xm[sourceKey][key];
},
set(newV) {
xm[sourceKey][key] = newV;
}
})
}
接下来说说具体响应式的实现,还是用图说话吧,是时候展现我的灵魂画工了。

其实就是对于每个数据而言,都有一个依赖收集器--dep,这个dep会保存有所有跟当前数据相关的观察者--watcher。而依赖收集的过程是在render函数被调用的阶段完成的,因为在调用render生成vnode的过程中,就用到了get属性;而在数据发生更改后,又通过调用set通知到了其观察者。
接下来就是撸码时间,细节的东西先不讨论,先把最基本的功能实现:
// 通过递归,遍历$data,定义响应式
function observe(obj) {
Object.entries(obj).forEach(([key, value]) => {
defineReactive(obj, key);
if(typeof value === 'object') observe(value);
})
}
// 给数据进行数据劫持
function defineReactive(target, key) {
let value = target[key];
let dep = new Dep();
Object.defineProperty(target, key, {
get() {
dep.depend();
Dep.target.addDep(dep);
return value;
},
set(newV) {
value = newV;
dep.notify();
}
})
}
Dep
import { addUpdateQueue } from './queue';
let id = 0;
class Dep {
// 静态属性,确保当前只有唯一的watcher正在执行
static target = null;
constructor() {
this.id = id++;
this.watchers = [];
}
depend() {
const watcherIds = this.watchers.map(item => item.id);
// 防止重复添加
if(Dep.target && !watcherIds.includes(Dep.target.id)) this.watchers.push(Dep.target);
}
changeWatcher(watcher) {
Dep.target = watcher;
}
notify() {
addUpdateQueue(this.watchers);
}
}
export default Dep;
watcher
let id = 0;
class Watcher {
constructor() {
this.id = id++;
this.deps = [];
}
addDep(dep) {
const depIds = this.deps.map(item => item.id);
if(dep && !depIds.includes(dep.id)) this.deps.push(dep);
}
run() {
console.log('i have update')
}
}
export default Watcher;
queue
// 将待更新的watcer添加至queue中,统一更新
let queue = [];
export const addUpdateQueue = function(watchers) {
const queueSet = new Set([...queue, ...watchers]);
queue = [...queueSet];
// 执行排序操作,排序逻辑还未定
// queue.sort();
// 这里后面会改为在nextTick中执行
queue.forEach(watcher => watcher.run());
}
然后我们只需在init的过程中调用一下render方法即可
// 在beforeMount前执行
// 这里对Dep.target的赋值先这样写,后续会进行改善
Dep.target = xm.$watcher = new Watcher('render', xm.$render);
xm._callHook.call(xm, 'beforeMount');
至此,我们的框架已经有了一个雏形,下一章节,我们会对生成VNode,并将其对应的部分插入至DOM中进行实现,请耐心等待更新......
PS:如果觉得本文对您有帮助,麻烦动动小爪给个赞或star,谢谢。
第二章:从零开始,采用Vue的思想,开发一个自己的JS框架(二):首次渲染