阅读 791

从零开始,采用Vue的思想,开发一个自己的JS框架(一):基本架构的搭建

前言

本系列文章将记录我尝试着去开发一个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;
    }
  })
}
复制代码

接下来说说具体响应式的实现,还是用图说话吧,是时候展现我的灵魂画工了。

avatar

其实就是对于每个数据而言,都有一个依赖收集器--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框架(二):首次渲染

第三章:从零开始,采用Vue的思想,开发一个自己的JS框架(三):update和diff

第四章:从零开始,采用Vue的思想,开发一个自己的JS框架(四):组件化和路由组件

关注下面的标签,发现更多相似文章
评论