这篇博客记录自己学习的感悟,也给一些学习vue3的同学一些面试技巧,实战经验。
对于急迫想提升的同学来说阅读源码不仅仅是面试效果提升,更是个人能力提升的关键。首先我不反感,那些源码很熟,或者某个版本更新了什么,都是十分清楚的人,至少是个有心人。但是作为面试细节我可能觉得有些矫枉过正了,个人观点。
好的源码有很好的代码规范,项目规范,好的设计模式使用场景,好的数据结构设计,好的方案思路。天下文章一大抄,正是基于前人的经验和方法,也是我们构建自己项目的基石和创新基础。这个真的不是废话,一定要先从学习别人的设计开始,千万不要自以为是的创造,说真的百分之99的人没有那个能力,留下的只能是坑。
纯看代码是枯燥乏味的,也是体会不深的,过段时间可能就忘却了,这是自己的真实感受,代码永远是写出来的,不是看出来的。
首先还是要看的,看什么?看尤雨溪对vue实现方式的视频,看作者关于vue3一些官方介绍。还有看什么,在细节上面可能视频表述有限,有些对于vue3解读的博客文章,书籍都可以看,例如现在我看的这本《Vue设计与实现》,对数据劫持,响应式的概念有所了解。
看书笔记,学习千万不要停留在表面一定要有自己的思考和沉淀,虽然很感谢作者的分享与总结,但自己在工作中也是有一些体会能不能和读书笔记相结合。
代码实践,自己手动去写一下响应式的实现方式,任务调度的设计。
关注设计流程,虽然不仅仅是下面这些流程,但是这些知识的梳理应该是足够我完成一个简单的响应式的ui框架核心开发。
数据劫持 --> 依赖收集 --> 任务调度 --> 虚拟dom --> diff算法
具体实践?删除代码中无效的代码,提炼关键函数!!!
export function reactive(target: object) {
// if trying to observe a readonly proxy, return the readonly version.
if (isReadonly(target)) {
return target
}
return createReactiveObject(
target,
false,
mutableHandlers,
mutableCollectionHandlers,
reactiveMap
)
}
function createReactiveObject(
target: Target,
isReadonly: boolean,
baseHandlers: ProxyHandler<any>,
collectionHandlers: ProxyHandler<any>,
proxyMap: WeakMap<Target, any>
) {
if (!isObject(target)) {
if (__DEV__) {
console.warn(`value cannot be made reactive: ${String(target)}`)
}
return target
}
// target is already a Proxy, return it.
// exception: calling readonly() on a reactive object
if (
target[ReactiveFlags.RAW] &&
!(isReadonly && target[ReactiveFlags.IS_REACTIVE])
) {
return target
}
// target already has corresponding Proxy
const existingProxy = proxyMap.get(target)
if (existingProxy) {
return existingProxy
}
// only a whitelist of value types can be observed.
const targetType = getTargetType(target)
if (targetType === TargetType.INVALID) {
return target
}
const proxy = new Proxy(
target,
targetType === TargetType.COLLECTION ? collectionHandlers : baseHandlers
)
proxyMap.set(target, proxy)
return proxy
}
因为源码中有很多,健壮性以及辅助开发的部分代码,但是并不是我们关心的核心。我们自己去编写这段代码时,可去掉这些代码。我在具体实现的时候,就删除了一些代码变成了下面的样子。
function reactive<T>(target: T) {
return createReactiveObject(target)
}
function createReactiveObject<T extends Object>(
target: T,
) {
// 创建代理对象实现数据劫持
const proxy = new Proxy(
target,
{
get(target: any, key: string) {
// 依赖收集
track(target, key)
return target[key];
},
set<T>(target: any, key: string, value: T) {
const oldValue = target[key];
target[key] = value
trigger(target, key, value, oldValue)
return true
}
}
)
return proxy
}
由此可见,我不仅仅删除了很多健壮性的代码,以及开发提示代码,还有合并的部分函数功能。但是缩短后的代码,我在阅读上面心智负担比较小。
工欲善其事必先利其器,这是我一贯的思想,我开始就在想,怎么去搭建一个vue3源码的学习环境。最大的初衷就是学习,不畏难。可选的技术有webpack,vite这两个常见的打包方式,既然esbuild已经是作为vite的很重要的部分了,甚至有了webpack插件,umijs的插件等等,那我为什么不用esbuild作为构建基础呢?,更进一步为什么我不用golang作为我的构建基础吗?再也不用担心node_module,以及编译速度了。ts和js的选择也是毫无疑问的选择了ts。
mkdir goVue
cd goVue
go mod init github.com/fodelf/goVue
查看了esbuild的官方文档serve,虽然有sever服务,但是在热更新上面支持度不够友好,不能reload页面,更别说hmr了。这是我比较痛苦的点,验证影响我开发体验,虽然编译速度变快了,但是开发体验并没有得到提升还是要重新刷新页面。
基于这个serve方式的改造是一种实现方案,我就简单粗暴一点直接使用golang启动了一个web服务,然后使用websocket进行通知更新,构建完成后刷新页面。也算完成了我基本的述求。
开发永远是阶段性的,方案永远是阶段性的,人总是在趋于完美的道路上不断前行。先实现再优化永远是我的开发思路,比如我现在关注的问题核心是vue3源码的实现,我现在很low的方式也能实现开发效果,我希望把我每天晚上更多的有效时间放到,vue3的实践当中。我也肯定知道基于esbuild的修改有更好的hmr的实践,但不是我的主要矛盾。在学习vue的过程中,我应该顺带解决这个问题。
这里面有三个开发核心要素,功能需求有限,管理自己的技术负债,提升质量属性。
实现 --> 优化 --> 自动化
这个流程是要关注的,我可以实现vue3的核心逻辑,我优化了Vue3编译速度,我通过单元测试保证了后续的开发质量与提测基准。
可以看到我确实讲了很多废话,但是我相信这些都是我最真实的心路历程,希望大家在开发的道路上面共勉。
最终的实现变扭版,也相关事宜esbuild的Plugin做一些强化,但是好像场景是不匹配的。终止肯定会更好。
/*
* @Description: esbuild构建相关
* @Author: 吴文周
* @Github: https://github.com/fodelf
* @Date: 2022-03-12 06:24:21
* @LastEditors: 吴文周
* @LastEditTime: 2022-03-12 06:32:43
*/
package pkg
import (
"fmt"
"os"
"path"
"github.com/evanw/esbuild/pkg/api"
)
func BuildInit (){
dir, _ := os.Getwd()
api.Build(api.BuildOptions{
// LogLevel: api.LogLevelInfo,
EntryPoints: []string{"src/index.ts"},
// Outdir: "www/js",
Outfile: path.Join(dir, "www", "js", "index.js"),
Bundle: true,
Sourcemap: api.SourceMapLinked,
// Plugins: []api.Plugin{hmr},
Write: true,
Format: api.FormatESModule,
Watch: &api.WatchMode{
OnRebuild: func(result api.BuildResult) {
if len(result.Errors) > 0 {
fmt.Printf("watch build failed: %d errors\n", len(result.Errors))
} else {
fmt.Println(result.OutputFiles[0].Path)
for _, value := range NodeCache{
//fmt.Println(index, "\t",value)
b := []byte(result.OutputFiles[0].Path)
value.DataQueue <- b
// fmt.Println(value)
}
}
},
},
})
}
/*
* @Description: websocket
* @Author: 吴文周
* @Github: https://github.com/fodelf
* @Date: 2022-03-12 06:20:34
* @LastEditors: 吴文周
* @LastEditTime: 2022-03-12 06:34:09
*/
package pkg
import (
"fmt"
"net/http"
"sync"
"github.com/gorilla/websocket"
)
// 单个websocket链接节点
type Node struct {
Conn *websocket.Conn
//并行转串行,
DataQueue chan []byte
}
//读写锁
var rwlocker sync.RWMutex
//websocket 链接缓存
var NodeCache =[]Node{}
func Message(writer http.ResponseWriter, request *http.Request) {
conn, err := (&websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
},
}).Upgrade(writer, request, nil)
if err != nil {
fmt.Print("出错了")
return
}
rwlocker.Lock()
//获得websocket链接conn
node := Node{
Conn: conn,
DataQueue: make(chan []byte, 1000),
}
NodeCache = append(NodeCache,node)
rwlocker.Unlock()
go sendproc(node)
}
//发送逻辑
func sendproc(node Node) {
defer func() {
if r := recover(); r != nil {
fmt.Println("write stop")
}
}()
defer func() {
node.Conn.Close()
// fmt.Println("Client发送数据 defer")
}()
for {
select {
case data := <-node.DataQueue:
// fmt.Printf("发送消息")
err := node.Conn.WriteMessage(websocket.TextMessage, data)
if err != nil {
// fmt.Printf("发送消息失败")
// for i := 0; i < len(NodeCache); i++ {
// // fmt.Println(NodeCache[i].Conn)
// // fmt.Println(node.Conn)
// if cmpare2(NodeCache[i].Conn , node.Conn) {
// NodeCache = append(NodeCache[:i], NodeCache[i+1:]...)
// fmt.Printf("删除节点")
// }
// }
return
}
}
}
}
/*
* @Description: 打开浏览器
* @Author: 吴文周
* @Github: https://github.com/fodelf
* @Date: 2022-03-11 10:36:26
* @LastEditors: 吴文周
* @LastEditTime: 2022-03-12 06:31:17
*/
package pkg
import (
"os/exec"
"runtime"
)
func Open(url string) error {
var cmd string
var args []string
switch runtime.GOOS {
case "windows":
cmd = "cmd"
args = []string{"/c", "start"}
case "darwin":
cmd = "open"
default: // "linux", "freebsd", "openbsd", "netbsd"
cmd = "xdg-open"
}
args = append(args, url)
return exec.Command(cmd, args...).Start()
}
/*
* @Description: web服务
* @Author: 吴文周
* @Github: https://github.com/fodelf
* @Date: 2022-03-12 06:28:23
* @LastEditors: 吴文周
* @LastEditTime: 2022-03-12 06:31:26
*/
package pkg
import (
"fmt"
"net/http"
"os"
"path"
)
func Serveinit() {
dir, _ := os.Getwd()
http.Handle("/", http.FileServer(http.Dir(path.Join(dir, "www"))))
http.HandleFunc("/message", Message)
done := make(chan bool)
go http.ListenAndServe(":8080", nil)
fmt.Println("serve stsart at http://localhost:8080")
Open("http://localhost:8080")
<-done
}
渲染器的作用就是把虚拟dom转换为真正dom
虚拟dom --> 渲染器 --> 真实dom
其实大家对react的render 函数,从react的场景上面我们知道,需要一个html的模板,需要一个挂载的root就是可以了。
const element = <h1>Hello, world</h1>;
ReactDOM.render(element, document.getElementById('root'));
渲染器我理解的话就是两个入参 一个dom的模板或者虚拟dom,一个是挂载的容器。通过递归调用的方式将js对象变成了dom对象,并且插入到容器里面。
// 节点定义
interface VNode {
tag: string;
props: Record<string, Function>;
children: VNode[] | String;
}
// 虚拟dom
const vnode = {
tag: "div",
props: {
onClick: () => {
console.log("hello word");
},
},
children: [
{
tag: "span",
props: {
onClick: () => {
console.log("hello child");
},
},
children: "我是child",
},
],
};
/**
* @name: render
* @description: 渲染方法
* @param {VNode} vnode
* @param {HTMLElement} root
*/
function render(vnode: VNode, root: HTMLElement) {
//创建html元素
const el = document.createElement(vnode.tag);
// 绑定事件
const props = vnode.props;
for (let key in vnode.props) {
// 截取onClick中的Click名称,转小写
const eventName = key.substring(2).toLowerCase();
el.addEventListener(
eventName as keyof HTMLElementEventMap,
props[key] as EventListenerOrEventListenerObject
);
}
// 如果是文本直接渲染,如果是数组需要递归渲染逻辑
if (typeof vnode.children === "string") {
el.innerHTML = vnode.children;
} else {
const children = vnode.children as VNode[];
children.forEach((child) => {
render(child, el);
});
}
//添加到容器中
root.appendChild(el);
}
render(vnode, document.getElementById("container") as HTMLElement);
由上可知虚拟dom可以通过渲染器变成真实dom,那组件又是什么呢?组件是一个自定义命名的标签,标签是变成渲染结果进入文档节点当中的呢?
<div>
里面是我的内容区
<MyComponent></MyComponent>
</div>
上述代码中MyComponent组件是变成渲染dom呢?猜一下? 是不是这样的就可以 ?
// 组件函数
function MyComponent(){
return {
tag: "div",
props: {
onClick: () => {
console.log("hello word");
},
},
}
}
// 虚拟dom
const vnode = {
tag: MyComponent,
}
由此可见 组件可以是一个函数,返回了一个虚拟dom,剩下的还是那个嵌套递归的render,这样就可以把我们的组件渲染出来了。
function render(vnode: VNode, root: HTMLElement) {
// 判断tag类型
if (typeof vnode.tag == "string") {
//创建html元素
const el = document.createElement(vnode.tag);
// 绑定事件
const props = vnode.props;
for (let key in vnode.props) {
const eventName = key.substring(2).toLowerCase();
el.addEventListener(
eventName as keyof HTMLElementEventMap,
props[key] as EventListenerOrEventListenerObject
);
}
// 如果是文本直接渲染,如果是数组需要递归渲染逻辑
if (typeof vnode.children === "string") {
el.innerHTML = vnode.children;
} else {
const children = vnode.children as VNode[];
children.forEach((child) => {
render(child, el);
});
}
//添加到容器中
root.appendChild(el);
} else {
// 渲染组件
render(vnode.tag(), root);
}
}
核心逻辑就是判断tag的数据类型如果是function就不使用createElement,而是再次调用render方法 render(vnode.tag(), root) 这样理解的话就可以推断,react的组件是否也是如此实现的?下面就是react组件实现的一种方式。场景上面react有class组件和function组件,类型判断的时候也是要注意一下。
// 借用解决大佬代码了
function createDOM(vdom){
let {type,props} = vdom;
let dom;
if(type === REACT_TEXT){
dom = document.createTextNode(props.content);
}else if(typeof type === 'function'){ // 组件
if(type.isReactComponent){ // 类组件
return mountClassComponent(vdom);
}else{ // 函数组件
return mountFunctionComponent(vdom);
}
}else{
dom = document.createElement(props.content);
}
if(props){
updateProps(dom,{},props);
if(typeof props.children == 'object' && props.children.type){
render(props.children,dom)
}else if(Array.isArray(props.children)){
reconcileChildren(props.children,dom)
}
}
vdom.dom = dom
return dom
}
模板语言和jsx 虽然实现方式各异,但是殊途同归,都是先转换为虚拟dom,然后再调用render 渲染器将虚拟dom转换为真实dom,组件的实现亦是如此。
本以为复杂的源码阅读之路,其实有机会举一反三,在面试时又多了一套说辞。
其实一直强调的那一点就是所有的设计都是基于前人的经验去创新,而不是自己闭门造车。
组件一定是函数吗?其实无所谓了只要传入的是一个虚拟dom的对象,这样定义的object也是可以的。其实我们在用vue3+tsx就可以知道,只要有render函数(此render和之前的render渲染器不同)返回的是一个可以渲染的模板就可以,甚至于说不用render返回,只要能返回一个渲染模板,就可以实现。
我们在vue3中写不写render都可以。
// 写法一
export default defineComponent({
name: 'Test',
render:() {
return <div>hello world</div>
},
})
// 写法二
export default defineComponent({
name: 'Test',
setup() {
return () => <div>hello world</div>
}
})
这里有个人观点:作者的写作顺序有些偏向概念的由浅入深模式,例如先从声明式,命令式开始往下讲,其实还有一种方式,能不能从一个库的设计理念开始讲,也是一种方案,先从核心概念开始讲数据劫持,diff算法,编译渲染。一种是概念的由浅入深,一种是真正的vu3的实现核心方案。
各有利弊作者的方式作者的方式更利于新手去感受编程的基础概念等等,另一种方式更适合有一定工作经验的人去提示,写作上面也是鱼和熊掌不可兼得。
模板是声明式ui的表现形式。核心的思想还是编译器的概念。这也是前端受人诟病的一点,所谓的前端方言话,虽然编译器帮助我们实现了组件化提升了代码的可维护性。但是每个前端框架就是一种语法,实在是有些一言难尽。
编译原理顺带提一下,后面可以大篇幅的描述。我之前关于编译原理的浅析学习
<template>
<div @click ='handleCilck'>
点我
</div>
</template>
<script>
export default{
methods:{
handleCilck:()=>{
alert("Hello Word")
}
}
}
</script>
这段代码是不能在浏览器里面正常运行的。需要把这样的前端方言转换为浏览器可以执行的代码。经过编译器之后就会变成下面的样子。
// 这是手写render
export default{
methods:{
handleCilck:()=>{
alert("Hello Word")
}
},
render(){
h("div",{onClick:handleCilck},'点我')
}
}
// 然后进入渲染器
const handleCilck = ()=>{
alert("Hello Word")
}
const vnode = {
tag: "div",
props: {
onClick: handleCilck,
},
children:"点我"
}
// root是挂载点
render(vnode,root)
把一段不能被浏览器正常执行但是可维护性较高的代码,转换为js(可能还有less/sass转css),再使用渲染器将组建渲染到容器内。
编译器 --> 渲染器
很多设计其实是环环相扣的。基于场景我们去看,使用编译器编译模板,到虚拟dom,再到渲染器渲染,流程是清晰可见的。在此过程中,还有一些类似静态编译提升的场景优化。
<template>
<div>
{message}
</div>
</template>
<script>
export default{
data():{
return{
message: 'hello world'
}
},
}
</script>
<template>
<div>
hello word
</div>
</template>
我们看到这个两个模板是有明显区别的,第一个模板明显比第二个模板具有可变性,第二个组件可以说只要生成了就不会变,只要打上这些标记,在我们代码运行的过程中旧不需要对第二个组件进行相关的数据劫持等等操作了。查看变量提升工具这是作者提供的在线查询的地址。
有这些标记的目的肯定是为了提升速度,有兴趣的可以看b站尤雨溪自己vue3宣讲会的视频对这一点有着重描述。也是这一点对比react的这些方面做得欠佳的,其实核心还是jsx和模板语言的不同场景,没什么必要强行优劣,软件从业人员没必要拉踩,场景和技术选型是极大相关的,一定要基于自己的场景选择合适的技术,仅此而已。 原视频在21分钟左右。 当然整个视频值得我们反复观看,别听那些其他人讲,包括我这篇博客都是自己的理解,我们更应该听作者讲他的设计理念再去面试。