主要内容
- Vue响应式原理支持,对象属性劫持
- 实现对数组的方法劫持
- 模板编译原理,将模板转化成ast语法树
- 代码生成,实现虚拟DOM
- 通过虚拟DOM生成真实DOM
环境准备:
npm install rollup
//将高级语法转换为低级语法
npm install rollup rollup-plugin-babel @babel/core @babel/preset-env --save-dev
npm i @rollup/plugin-node-resolve
package.json
{
"scripts": {
"dev": "rollup -cw"
},
"devDependencies": {
"@babel/core": "^7.18.6",
"@babel/preset-env": "^7.18.6",
"rollup": "^2.75.7",
"rollup-plugin-babel": "^4.4.0"
}
}
.babelrc
{
"presets": [
"@babel/preset-env"
]
}
rollup.config.js
//rollup默认可以导出一个对象,作为打包的配置文件
import babel from 'rollup-plugin-babel'
export default {
input: './src/index.js', //入口
output: {
file: './dist/vue.js', //出口
name: 'Vue',
format: 'umd', //esm es6模块, commonjs模块 iife自执行函数 umd统一模块规范
sourcemap: true, //希望可以调试源代码
},
plugins: [
babel({
exclude: 'node_modules/**' //排除node_modules所有文件
})
]
}
打包命令:npm rundev
初始化数据
创建Vue实例
import { initMixin } from "./init";
function Vue(options){
// debugger
this._init(options)
}
//给Vue实例添加初始化方法,将用户选项挂载到实例上,并开始初始化状态
Vue.prototype._init = function(options){
//用于初始化操作
// $表示Vue自带的属性
const vm = this;
vm.$options = options;//将用户的选项挂载到实例上
//初始化状态
if(vm.$options.data){//
data = typeof data === 'function' ? data.call(vm) : data
}
}
劫持对象观测
遍历对象data中的每一个元素进行属性劫持,保证数据访问或者更新时能拦截,如果key存储的是对象则进行递归监测
class Observer{
constructor(data){
//Object.defineProperty只能劫持已经存在的属性,后续增加的无法监听(vue2会为此单独写一个例如 $set $delete的api)
Object.defineProperty(data, '__ob__', {
value: this, //将Observer类实例赋值给data的__ob__属性,如果数据上有这个属性则说明这个属性被观测过了
enumerable: false //将obj变成不可枚举,解决如果data初始是对象,在调用walker方法进行观测时,内部的observe方法会对__ob__属性所代表的data对象无限调用
})
if(Array.isArray(data)){
this.observeArray(data);//监测数组对象中的变化
}else{
this.walker(data)
}
}
walker(data){
//循环对象,对属性依次进行劫持
//重新定义属性,因为要重新构建所以性能很低
Object.keys(data).forEach(key => defineReactive(data, key, data[key]))
}
observeArray(data){//观测数组
data.forEach(item => observe(item))
}
}
export function defineReactive(target, key, value){//闭包 属性劫持
// debugger
observe(value) //对多层对象递归进行属性劫持
Object.defineProperty(target, key, {
get(){//取值的时候会执行get
return value
},
set(newV){
if(newV === value) return
observe(newV)
value = newV //Q_lys:这里有一点不是很明白,defineReactive中的参数value应该只在defineReactive的函数内部有效,为什么这里直接更改value会反向更改data中的值
// console.log('set data:', target)
}
})
}
export function observe(data){
// 对这个对象进行劫持
if(typeof data !== 'object' || data == null){
return; //只对对象进行劫持
}
if(data.__ob__ instanceof Observer){//说明这个对象被代理过了
return data.__ob__
}
// 如果一个对象被劫持过了,那就不需要再被劫持了(要判断一个对象是否被劫持过,可以增添一个实例,用实例来判断是否被劫持过)
return new Observer(data)
}
劫持数组观测
对Array的原型方法进行重写并返回,这里用到了一个很巧妙地方法,在Observer类中给data增添了一个__ob__属性,该属性存储的是Obsever实例对象,便于在array.js文件中能直接获取
observe
方法进行数组观测
array.js
let oldArrayProto = Array.prototype;//获取数组的原型
export let newArrayProto = Object.create(oldArrayProto)
let methods = [//所有会修改原数组的方法
'push',
'pop',
'shift',
'reverse',
'sort',
'splice'
]
methods.forEach(method => {
newArrayProto[method] = function(...args){//重写数组方法
//这里指的arr
const result = oldArrayProto[method].call(this, ...args)//内部调用原来的方法 函数劫持 切片编程
//对新增的数据进行劫持
let inserted;
let ob = this.__ob__;//拿到Observer实例
switch(method){
case 'push':
case 'unshift':
inserted = args;
break;
case 'splice': //Array.splice(idx, 删除的个数, 新增内容)
inserted = args.slice(2)
default:
break
}
if(inserted){//有新增的内容对新增数组进行观测
ob.observeArray(inserted)
}
return result
}
})
解析模板参数
el: ‘#app’ //将数据解析到el元素上
- 模板引擎 性能很差 正则匹配替换 vue1.0没有引入虚拟DOM的改变
- 采用寻DOM,数据变化后比较虚拟DOM的差异,最后更新需要更新的地方
- 核心就是需要将模板变成js语法,最后通过js语法生成虚拟DOM
先变成语法树,再重新组装成新的语法,将temolate语法转换成render语法
注意: 有现成的包解析html,htmlparser2
- script标签引用的vue.global.js这个编译过程是在浏览器运行的
- runtime是不包含模板编译的,整个编译是在打包的时候通过loader来转义.vue文件,用runtime的时候是不能使用模板template
正则表达式图形可视化网站
startTagOpen: ^<((?:[a-zA-Z_][\-\.0-9_a-zA-Z]*\:)?[a-zA-Z_][\-\.0-9_a-zA-Z]*)>
匹配标签名,例如:<div,标签名不能以数字开头,还能匹配带命名空间的标签:<div:xxx>
endTag: /^<\/((?:[a-zA-Z_][\-\.0-9_a-zA-Z]*\:)?[a-zA-Z_][\-\.0-9_a-zA-Z]*)[^>]*>/
属性匹配:/^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>
]+)))?/`
第一个分组就是属性的key,value就是分组3/分组4/分组5
转换抽象语法树AST
对html进行解析时,不断解析到开始标签<div
、结束标签>
、属性style="color:red"
、文本内容{{name}}
(普通文本hello
),解析一段便删除一段,直到删完
html
<div id="app">
<div style="color:red;font-size:14px">{{name}} hello</div>
<span>{{age}}</span>
</div>
解析开始标签及中间的属性和结束标签
function parseStartTag(){
const start = html.match(startTagOpen);
// console.log(start)
if(start){
const match = {
tagName: start[1],//标签名
attrs: []
}
advance(start[0].length);
// 如果不是开始标签的结束,就一直匹配属性
let attr, end;
while(!(end = html.match(startTagClose)) && (attr = html.match(attribute))){
advance(attr[0].length)
match.attrs.push({name: attr[1], value: attr[3] || attr[4] || attr[5] || true})
}
if(end){//删掉结束标签>
advance(end[0].length)
}
// console.log('match:',match)
return match
}
return false; //不是开始标签
}
解析流程: 通过判断html中<
的位置,如果位置为0,可能是开始标签<div style="color:red">
或者是结束标签(开始标签和文本内容都截取完后,只剩下</div>
),而如果位置大于0,则说明是文本内容(hello</div>
)。如果开始标签解析出来有内容,则说明接下来要解析文本内容,跳过下面的结束标签解析判断,提升代码性能。同理如果是结束标签解析完毕,则不会再进行解析文本内容也直接continue.
while(html){
// <div>hello</div>
// textEnd为0说明是一个开始标签或者结束标签
// 如果textEnd>0说明是文本的结束位置
let textEnd = html.indexOf('<'); //如果indexOf中的索引是0则说明是个标签
if(textEnd==0){
const startTagMathch = parseStartTag();
if(startTagMathch){//解析到的开始标签
start(startTagMathch.tagName, startTagMathch.attrs)
continue
}
let endTagMatch = html.match(endTag);
if(endTagMatch){
advance(endTagMatch[0].length);
end(endTagMatch[1])
continue;
}
// break
}
if(textEnd>0){
let text = html.substring(0, textEnd);//文本内容
if(text){
chars(text)
advance(text.length)
}
}
}
AST语法树:利用栈形结构创造树,将开始标签入栈,并创建AST节点,并创建父亲儿子指向,遇到结束标签则出栈。文本直接放到当前指向的节点中
//最终需要转换成一颗抽象语法树
const ELEMENT_TYPE = 1;
const TEXT_TYPE = 3;
const stack = []; //用于存放元素的,栈中最后一个元素是当前匹配到开始标签的父亲
let currentParent;//指向栈中的最后一个
let root
function createASTElement(tag, attrs){//抽象语法树的节点,标签,类型,父亲,儿子,属性
return {
tag,
type: ELEMENT_TYPE,
children: [],
attrs,
parent: null
}
}
function start(tag, attr){//开始标签内容
let node = createASTElement(tag, attr);//创造一个ast节点
if(!root){
root = node;//如果root为空,则该节点为树的根节点
}
if(currentParent){
node.parent = currentParent
currentParent.children.push(node)
}
stack.push(node);
currentParent = node;//currentParent为栈中最后一个
}
function chars(text){//文本内容
text = text.replace(/\s/g, '')
text && currentParent.children.push({
type: TEXT_TYPE,
text,
parent: currentParent
})
}
function end(tag){//可以校验标签是否合法
stack.pop();
currentParent = stack[stack.length-1]
}
上面的HTML结构就会解析成如下的抽象语法树
代码生成
代码生成,将抽象语法树转换成render方法
_c(tag, attrs, children, text)
:这个函数是要创建一个tag的元素,并且它的属性是attrs,儿子是children,文本内容是text_s(变量)
:这个函数的作用是将要插值表达式中的变量{{name}}
转成字符串_v(text)
:这个函数的作用是要创建文本的
import { parseHTMl } from "./parse";
function genProps(attrs){
let str = ''//
for(let i=0;i<attrs.length;i++){
let attr = attrs[i];
let obj = {}
if(attr.name === 'style'){
// color:red;background:red => {color:'red'}
attr.value.split(';').forEach(el=>{//qs库
let [key, value] = el.split(':')
obj[key] = value
})
attr.value = obj
}
str += `${attr.name}:${JSON.stringify(attr.value)},`
}
return `{${str.slice(0,-1)}}`
}
const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g //{{any}}匹配到的内容就是表达式的变量
function gen(node){
if(node.type === 1){
return codegen(node);//如果孩子是元素则调用codegen生成
}else{
let text = node.text
if(!defaultTagRE.test(text)){//普通文本
return `_v(${JSON.stringify(text)})`
}else{//文本内容是带有{{}}
let tokens = []
let match;
defaultTagRE.lastIndex = 0;
let lastIndex = 0;
while(match = defaultTagRE.exec(text)){
let index = match.index;//匹配的位置 {{name}} hello {{age}} hello
if(index>lastIndex){//将中间hello匹配到
tokens.push(JSON.stringify(text.slice(lastIndex, index)))
}
tokens.push(`_s(${match[1].trim()})`)
lastIndex = index + match[0].length
}
if(lastIndex < text.length){//将最后的hello匹配到
tokens.push(JSON.stringify(text.slice(lastIndex)))
}
return `_v(${tokens.join('+')})`
}
}
}
function genChildren(children){
return children.map(child => gen(child)).join(',')
}
function codegen(ast){
// debugger
let children = genChildren(ast.children)
let code = (`_c('${ast.tag}',${ast.attrs.length>0 ? genProps(ast.attrs) : 'null'
}${ast.children.length ? `,${children}`:''
})`)
return code
}
export function compileToFunction(template){
// console.log('template:',template)
// 1.就是将template转换成ast语法树
let ast = parseHTMl(template);
console.log('ast:', ast)
//2.生成render方法,render方法执行后的返回结果就是虚拟DOM
// _c('div', {id:"app"},_c('div', {style:{"color":"red","font-size":"14px"}},_v(_s(name)+"hello")),_c('span', null,_v(_s(age))))
let code = codegen(ast)
// 模板引擎的实现原理 就是with+new Function
code = `with(this){return ${code}}`; //对象属性直接变成with作用域下的变量
let render = new Function(code);
// console.log('render:', render)
return render
}
实现虚拟DOM生成真实DOM
_c函数的具体实现:
// _c('div', attrs, ...childs)
Vue.prototype._c = function(){
return createElementVNode(this, ...arguments)
}
export function createElementVNode(vm, tag, data={}, ...children){
if(!data){
data = {}
}
let key = data.key;
if(key){
delete data.key
}
return vnode(vm,tag, key, data, children)
}
_s函数的具体实现:
Vue.prototype._s = function(value){
if(typeof value !== 'object') return value
return JSON.stringify(value)
}
_v函数的具体实现:
//_v(text)
Vue.prototype._v = function(){
return createTextVNode(this, ...arguments)
}
export function createTextVNode(vm, text){
return vnode(vm, undefined, undefined, undefined, undefined, text)
}
vnode:
function vnode(vm, tag, key, data, children, text){//这里的data就是ast中的属性
return {
vm,tag,key,data,children, text
}
}
通过上面的操作可以将render函数产生虚拟节点,下面将根据生成的虚拟节点创造真实DOM,
- patch函数: oldVNode如果是初渲染状态就是el挂载的节点,如果是更新状态就是待更新节点,vnode虚拟节点。根据虚拟节点生成真实节点,将原来的oldVNode删除,并在下面插入真实节点
function patch(oldVNode, vnode){
const isRealElement = oldVNode.nodeType;
if(isRealElement){//初渲染流程
const elm = oldVNode //获取真实元素
const parentElm = elm.parentNode; //获取父元素
let newElm = createElm(vnode)
console.log("newElm:", newElm)
parentElm.insertBefore(newElm, elm.nextSibling);//先在老节点下面插入新节点
parentElm.removeChild(elm);//删除老节点
}else{
//diff算法
}
}
- createElm(vnode):根据虚拟节点创造真实节点,并在虚拟节点上挂载真实节点,方便后续更新
function createElm(vnode){
let {tag, data, children, text} = vnode;
if(typeof tag === 'string'){//标签
vnode.el = document.createElement(tag)//虚拟节点上挂载了真实节点
patchProps(vnode.el, data);
children.forEach(child => {
vnode.el.appendChild(createElm(child))
});
}else{
vnode.el = document.createTextNode(text)
}
return vnode.el
}
function patchProps(el, props){
for(let key in props){
if(key === 'style'){
for(let styleName in props.style){
el.style[styleName] = props.style[styleName];
}
}else{
el.setAttribute(key, props[key]);
}
}
}
Vue核心流程
- 创造响应式数据
- 模板转换成AST语法树
- 将AST语法树转换成render函数,render函数会去产生虚拟节点(使用响应式数据),后续每次数据更新可以只执行render函数(无需再次执行ast转换过程)
- 根据生成的虚拟节点创造真实DOM